feat(coderd): add filters and fix template for provisioner daemons (#16558)

This change adds provisioner daemon ID filter to the provisioner daemons
endpoint, and also implements the limiting to 50 results.

Test coverage is greatly improved and template information for jobs
associated to the daemon was also fixed.

Updates #15084
Updates #15192
Related #16532
This commit is contained in:
Mathias Fredriksson
2025-02-14 17:26:46 +02:00
committed by GitHub
parent a69961bbd2
commit 77306f3de1
13 changed files with 532 additions and 64 deletions

View File

@ -21,7 +21,7 @@
"previous_job": {
"id": "======[workspace build job ID]======",
"status": "succeeded",
"template_name": "",
"template_name": "test-template",
"template_icon": "",
"template_display_name": ""
},

37
coderd/apidoc/docs.go generated
View File

@ -2976,6 +2976,43 @@ const docTemplate = `{
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query"
},
{
"type": "array",
"format": "uuid",
"items": {
"type": "string"
},
"description": "Filter results by job IDs",
"name": "ids",
"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"
},
{
"type": "object",
"description": "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})",

View File

@ -2610,6 +2610,43 @@
"in": "path",
"required": true
},
{
"type": "integer",
"description": "Page limit",
"name": "limit",
"in": "query"
},
{
"type": "array",
"format": "uuid",
"items": {
"type": "string"
},
"description": "Filter results by job IDs",
"name": "ids",
"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"
},
{
"type": "object",
"description": "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})",

View File

@ -3931,7 +3931,7 @@ func (q *FakeQuerier) GetProvisionerDaemonsByOrganization(_ context.Context, arg
return daemons, nil
}
func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(_ context.Context, arg database.GetProvisionerDaemonsWithStatusByOrganizationParams) ([]database.GetProvisionerDaemonsWithStatusByOrganizationRow, error) {
func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg database.GetProvisionerDaemonsWithStatusByOrganizationParams) ([]database.GetProvisionerDaemonsWithStatusByOrganizationRow, error) {
err := validateDatabaseType(arg)
if err != nil {
return nil, err
@ -3981,6 +3981,31 @@ func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(_ context.Co
status = database.ProvisionerDaemonStatusIdle
}
}
var currentTemplate database.Template
if currentJob.ID != uuid.Nil {
var input codersdk.ProvisionerJobInput
err := json.Unmarshal(currentJob.Input, &input)
if err != nil {
return nil, err
}
if input.WorkspaceBuildID != nil {
b, err := q.getWorkspaceBuildByIDNoLock(ctx, *input.WorkspaceBuildID)
if err != nil {
return nil, err
}
input.TemplateVersionID = &b.TemplateVersionID
}
if input.TemplateVersionID != nil {
v, err := q.getTemplateVersionByIDNoLock(ctx, *input.TemplateVersionID)
if err != nil {
return nil, err
}
currentTemplate, err = q.getTemplateByIDNoLock(ctx, v.TemplateID.UUID)
if err != nil {
return nil, err
}
}
}
var previousJob database.ProvisionerJob
for _, job := range q.provisionerJobs {
@ -3997,6 +4022,31 @@ func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(_ context.Co
}
}
}
var previousTemplate database.Template
if previousJob.ID != uuid.Nil {
var input codersdk.ProvisionerJobInput
err := json.Unmarshal(previousJob.Input, &input)
if err != nil {
return nil, err
}
if input.WorkspaceBuildID != nil {
b, err := q.getWorkspaceBuildByIDNoLock(ctx, *input.WorkspaceBuildID)
if err != nil {
return nil, err
}
input.TemplateVersionID = &b.TemplateVersionID
}
if input.TemplateVersionID != nil {
v, err := q.getTemplateVersionByIDNoLock(ctx, *input.TemplateVersionID)
if err != nil {
return nil, err
}
previousTemplate, err = q.getTemplateByIDNoLock(ctx, v.TemplateID.UUID)
if err != nil {
return nil, err
}
}
}
// Get the provisioner key name
var keyName string
@ -4008,13 +4058,19 @@ func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(_ context.Co
}
rows = append(rows, database.GetProvisionerDaemonsWithStatusByOrganizationRow{
ProvisionerDaemon: daemon,
Status: status,
KeyName: keyName,
CurrentJobID: uuid.NullUUID{UUID: currentJob.ID, Valid: currentJob.ID != uuid.Nil},
CurrentJobStatus: database.NullProvisionerJobStatus{ProvisionerJobStatus: currentJob.JobStatus, Valid: currentJob.ID != uuid.Nil},
PreviousJobID: uuid.NullUUID{UUID: previousJob.ID, Valid: previousJob.ID != uuid.Nil},
PreviousJobStatus: database.NullProvisionerJobStatus{ProvisionerJobStatus: previousJob.JobStatus, Valid: previousJob.ID != uuid.Nil},
ProvisionerDaemon: daemon,
Status: status,
KeyName: keyName,
CurrentJobID: uuid.NullUUID{UUID: currentJob.ID, Valid: currentJob.ID != uuid.Nil},
CurrentJobStatus: database.NullProvisionerJobStatus{ProvisionerJobStatus: currentJob.JobStatus, Valid: currentJob.ID != uuid.Nil},
CurrentJobTemplateName: currentTemplate.Name,
CurrentJobTemplateDisplayName: currentTemplate.DisplayName,
CurrentJobTemplateIcon: currentTemplate.Icon,
PreviousJobID: uuid.NullUUID{UUID: previousJob.ID, Valid: previousJob.ID != uuid.Nil},
PreviousJobStatus: database.NullProvisionerJobStatus{ProvisionerJobStatus: previousJob.JobStatus, Valid: previousJob.ID != uuid.Nil},
PreviousJobTemplateName: previousTemplate.Name,
PreviousJobTemplateDisplayName: previousTemplate.DisplayName,
PreviousJobTemplateIcon: previousTemplate.Icon,
})
}
@ -4022,6 +4078,10 @@ func (q *FakeQuerier) GetProvisionerDaemonsWithStatusByOrganization(_ context.Co
return a.ProvisionerDaemon.CreatedAt.Compare(b.ProvisionerDaemon.CreatedAt)
})
if arg.Limit.Valid && arg.Limit.Int32 > 0 && len(rows) > int(arg.Limit.Int32) {
rows = rows[:arg.Limit.Int32]
}
return rows, nil
}

View File

@ -208,6 +208,8 @@ type sqlcQuerier interface {
GetPreviousTemplateVersion(ctx context.Context, arg GetPreviousTemplateVersionParams) (TemplateVersion, error)
GetProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, error)
GetProvisionerDaemonsByOrganization(ctx context.Context, arg GetProvisionerDaemonsByOrganizationParams) ([]ProvisionerDaemon, error)
// Current job information.
// Previous job information.
GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg GetProvisionerDaemonsWithStatusByOrganizationParams) ([]GetProvisionerDaemonsWithStatusByOrganizationRow, error)
GetProvisionerJobByID(ctx context.Context, id uuid.UUID) (ProvisionerJob, error)
GetProvisionerJobTimingsByJobID(ctx context.Context, jobID uuid.UUID) ([]ProvisionerJobTiming, error)

View File

@ -5743,9 +5743,12 @@ SELECT
current_job.job_status AS current_job_status,
previous_job.id AS previous_job_id,
previous_job.job_status AS previous_job_status,
COALESCE(tmpl.name, ''::text) AS current_job_template_name,
COALESCE(tmpl.display_name, ''::text) AS current_job_template_display_name,
COALESCE(tmpl.icon, ''::text) AS current_job_template_icon
COALESCE(current_template.name, ''::text) AS current_job_template_name,
COALESCE(current_template.display_name, ''::text) AS current_job_template_display_name,
COALESCE(current_template.icon, ''::text) AS current_job_template_icon,
COALESCE(previous_template.name, ''::text) AS previous_job_template_name,
COALESCE(previous_template.display_name, ''::text) AS previous_job_template_display_name,
COALESCE(previous_template.icon, ''::text) AS previous_job_template_icon
FROM
provisioner_daemons pd
JOIN
@ -5771,43 +5774,62 @@ LEFT JOIN
)
)
LEFT JOIN
template_versions version ON version.id = (current_job.input->>'template_version_id')::uuid
workspace_builds current_build ON current_build.id = CASE WHEN current_job.input ? 'workspace_build_id' THEN (current_job.input->>'workspace_build_id')::uuid END
LEFT JOIN
templates tmpl ON tmpl.id = version.template_id
-- We should always have a template version, either explicitly or implicitly via workspace build.
template_versions current_version ON current_version.id = CASE WHEN current_job.input ? 'template_version_id' THEN (current_job.input->>'template_version_id')::uuid ELSE current_build.template_version_id END
LEFT JOIN
templates current_template ON current_template.id = current_version.template_id
LEFT JOIN
workspace_builds previous_build ON previous_build.id = CASE WHEN previous_job.input ? 'workspace_build_id' THEN (previous_job.input->>'workspace_build_id')::uuid END
LEFT JOIN
-- We should always have a template version, either explicitly or implicitly via workspace build.
template_versions previous_version ON previous_version.id = CASE WHEN previous_job.input ? 'template_version_id' THEN (previous_job.input->>'template_version_id')::uuid ELSE previous_build.template_version_id END
LEFT JOIN
templates previous_template ON previous_template.id = previous_version.template_id
WHERE
pd.organization_id = $2::uuid
AND (COALESCE(array_length($3::uuid[], 1), 0) = 0 OR pd.id = ANY($3::uuid[]))
AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, $4::tagset))
ORDER BY
pd.created_at ASC
LIMIT
$5::int
`
type GetProvisionerDaemonsWithStatusByOrganizationParams struct {
StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
IDs []uuid.UUID `db:"ids" json:"ids"`
Tags StringMap `db:"tags" json:"tags"`
StaleIntervalMS int64 `db:"stale_interval_ms" json:"stale_interval_ms"`
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
IDs []uuid.UUID `db:"ids" json:"ids"`
Tags StringMap `db:"tags" json:"tags"`
Limit sql.NullInt32 `db:"limit" json:"limit"`
}
type GetProvisionerDaemonsWithStatusByOrganizationRow struct {
ProvisionerDaemon ProvisionerDaemon `db:"provisioner_daemon" json:"provisioner_daemon"`
Status ProvisionerDaemonStatus `db:"status" json:"status"`
KeyName string `db:"key_name" json:"key_name"`
CurrentJobID uuid.NullUUID `db:"current_job_id" json:"current_job_id"`
CurrentJobStatus NullProvisionerJobStatus `db:"current_job_status" json:"current_job_status"`
PreviousJobID uuid.NullUUID `db:"previous_job_id" json:"previous_job_id"`
PreviousJobStatus NullProvisionerJobStatus `db:"previous_job_status" json:"previous_job_status"`
CurrentJobTemplateName string `db:"current_job_template_name" json:"current_job_template_name"`
CurrentJobTemplateDisplayName string `db:"current_job_template_display_name" json:"current_job_template_display_name"`
CurrentJobTemplateIcon string `db:"current_job_template_icon" json:"current_job_template_icon"`
ProvisionerDaemon ProvisionerDaemon `db:"provisioner_daemon" json:"provisioner_daemon"`
Status ProvisionerDaemonStatus `db:"status" json:"status"`
KeyName string `db:"key_name" json:"key_name"`
CurrentJobID uuid.NullUUID `db:"current_job_id" json:"current_job_id"`
CurrentJobStatus NullProvisionerJobStatus `db:"current_job_status" json:"current_job_status"`
PreviousJobID uuid.NullUUID `db:"previous_job_id" json:"previous_job_id"`
PreviousJobStatus NullProvisionerJobStatus `db:"previous_job_status" json:"previous_job_status"`
CurrentJobTemplateName string `db:"current_job_template_name" json:"current_job_template_name"`
CurrentJobTemplateDisplayName string `db:"current_job_template_display_name" json:"current_job_template_display_name"`
CurrentJobTemplateIcon string `db:"current_job_template_icon" json:"current_job_template_icon"`
PreviousJobTemplateName string `db:"previous_job_template_name" json:"previous_job_template_name"`
PreviousJobTemplateDisplayName string `db:"previous_job_template_display_name" json:"previous_job_template_display_name"`
PreviousJobTemplateIcon string `db:"previous_job_template_icon" json:"previous_job_template_icon"`
}
// Current job information.
// Previous job information.
func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.Context, arg GetProvisionerDaemonsWithStatusByOrganizationParams) ([]GetProvisionerDaemonsWithStatusByOrganizationRow, error) {
rows, err := q.db.QueryContext(ctx, getProvisionerDaemonsWithStatusByOrganization,
arg.StaleIntervalMS,
arg.OrganizationID,
pq.Array(arg.IDs),
arg.Tags,
arg.Limit,
)
if err != nil {
return nil, err
@ -5837,6 +5859,9 @@ func (q *sqlQuerier) GetProvisionerDaemonsWithStatusByOrganization(ctx context.C
&i.CurrentJobTemplateName,
&i.CurrentJobTemplateDisplayName,
&i.CurrentJobTemplateIcon,
&i.PreviousJobTemplateName,
&i.PreviousJobTemplateDisplayName,
&i.PreviousJobTemplateIcon,
); err != nil {
return nil, err
}

View File

@ -45,9 +45,12 @@ SELECT
current_job.job_status AS current_job_status,
previous_job.id AS previous_job_id,
previous_job.job_status AS previous_job_status,
COALESCE(tmpl.name, ''::text) AS current_job_template_name,
COALESCE(tmpl.display_name, ''::text) AS current_job_template_display_name,
COALESCE(tmpl.icon, ''::text) AS current_job_template_icon
COALESCE(current_template.name, ''::text) AS current_job_template_name,
COALESCE(current_template.display_name, ''::text) AS current_job_template_display_name,
COALESCE(current_template.icon, ''::text) AS current_job_template_icon,
COALESCE(previous_template.name, ''::text) AS previous_job_template_name,
COALESCE(previous_template.display_name, ''::text) AS previous_job_template_display_name,
COALESCE(previous_template.icon, ''::text) AS previous_job_template_icon
FROM
provisioner_daemons pd
JOIN
@ -72,16 +75,30 @@ LEFT JOIN
LIMIT 1
)
)
-- Current job information.
LEFT JOIN
template_versions version ON version.id = (current_job.input->>'template_version_id')::uuid
workspace_builds current_build ON current_build.id = CASE WHEN current_job.input ? 'workspace_build_id' THEN (current_job.input->>'workspace_build_id')::uuid END
LEFT JOIN
templates tmpl ON tmpl.id = version.template_id
-- We should always have a template version, either explicitly or implicitly via workspace build.
template_versions current_version ON current_version.id = CASE WHEN current_job.input ? 'template_version_id' THEN (current_job.input->>'template_version_id')::uuid ELSE current_build.template_version_id END
LEFT JOIN
templates current_template ON current_template.id = current_version.template_id
-- Previous job information.
LEFT JOIN
workspace_builds previous_build ON previous_build.id = CASE WHEN previous_job.input ? 'workspace_build_id' THEN (previous_job.input->>'workspace_build_id')::uuid END
LEFT JOIN
-- We should always have a template version, either explicitly or implicitly via workspace build.
template_versions previous_version ON previous_version.id = CASE WHEN previous_job.input ? 'template_version_id' THEN (previous_job.input->>'template_version_id')::uuid ELSE previous_build.template_version_id END
LEFT JOIN
templates previous_template ON previous_template.id = previous_version.template_id
WHERE
pd.organization_id = @organization_id::uuid
AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pd.id = ANY(@ids::uuid[]))
AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pd.tags::tagset, @tags::tagset))
ORDER BY
pd.created_at ASC;
pd.created_at ASC
LIMIT
sqlc.narg('limit')::int;
-- name: DeleteOldProvisionerDaemons :exec
-- Delete provisioner daemons that have been created at least a week ago

View File

@ -1,6 +1,7 @@
package coderd
import (
"database/sql"
"net/http"
"github.com/coder/coder/v2/coderd/database"
@ -18,31 +19,50 @@ import (
// @Produce json
// @Tags Provisioning
// @Param organization path string true "Organization ID" format(uuid)
// @Param limit query int false "Page limit"
// @Param ids query []string false "Filter results by job IDs" format(uuid)
// @Param status query codersdk.ProvisionerJobStatus false "Filter results by status" enums(pending,running,succeeded,canceling,canceled,failed)
// @Param tags query object false "Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'})"
// @Success 200 {array} codersdk.ProvisionerDaemon
// @Router /organizations/{organization}/provisionerdaemons [get]
func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
var (
ctx = r.Context()
org = httpmw.OrganizationParam(r)
tagParam = r.URL.Query().Get("tags")
tags = database.StringMap{}
err = tags.Scan([]byte(tagParam))
ctx = r.Context()
org = httpmw.OrganizationParam(r)
)
if tagParam != "" && err != nil {
qp := r.URL.Query()
p := httpapi.NewQueryParamParser()
limit := p.PositiveInt32(qp, 50, "limit")
ids := p.UUIDs(qp, nil, "ids")
tagsRaw := p.String(qp, "", "tags")
p.ErrorExcessParams(qp)
if len(p.Errors) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid tags query parameter",
Detail: err.Error(),
Message: "Invalid query parameters.",
Validations: p.Errors,
})
return
}
tags := database.StringMap{}
if tagsRaw != "" {
if err := tags.Scan([]byte(tagsRaw)); err != nil {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid tags query parameter",
Detail: err.Error(),
})
return
}
}
daemons, err := api.Database.GetProvisionerDaemonsWithStatusByOrganization(
ctx,
database.GetProvisionerDaemonsWithStatusByOrganizationParams{
OrganizationID: org.ID,
StaleIntervalMS: provisionerdserver.StaleInterval.Milliseconds(),
Limit: sql.NullInt32{Int32: limit, Valid: limit > 0},
IDs: ids,
Tags: tags,
},
)
@ -68,8 +88,11 @@ func (api *API) provisionerDaemons(rw http.ResponseWriter, r *http.Request) {
}
if dbDaemon.PreviousJobID.Valid {
previousJob = &codersdk.ProvisionerDaemonJob{
ID: dbDaemon.PreviousJobID.UUID,
Status: codersdk.ProvisionerJobStatus(dbDaemon.PreviousJobStatus.ProvisionerJobStatus),
ID: dbDaemon.PreviousJobID.UUID,
Status: codersdk.ProvisionerJobStatus(dbDaemon.PreviousJobStatus.ProvisionerJobStatus),
TemplateName: dbDaemon.PreviousJobTemplateName,
TemplateIcon: dbDaemon.PreviousJobTemplateIcon,
TemplateDisplayName: dbDaemon.PreviousJobTemplateDisplayName,
}
}

View File

@ -1,27 +1,251 @@
package coderd_test
import (
"database/sql"
"encoding/json"
"strconv"
"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/testutil"
)
func TestGetProvisionerDaemons(t *testing.T) {
func TestProvisionerDaemons(t *testing.T) {
t.Parallel()
t.Run("OK", func(t *testing.T) {
db, ps := dbtestutil.NewDB(t,
dbtestutil.WithDumpOnFailure(),
//nolint:gocritic // Use UTC for consistent timestamp length in golden files.
dbtestutil.WithTimezone("UTC"),
)
client, _, coderdAPI := coderdtest.NewWithAPI(t, &coderdtest.Options{
IncludeProvisionerDaemon: false,
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)
// Create initial resources with a running provisioner.
firstProvisioner := coderdtest.NewTaggedProvisionerDaemon(t, coderdAPI, "default-provisioner", map[string]string{"owner": "", "scope": "organization"})
t.Cleanup(func() { _ = firstProvisioner.Close() })
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil)
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, template.ID)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
// Stop the provisioner so it doesn't grab any more jobs.
firstProvisioner.Close()
// Create a provisioner that's working on a job.
pd1 := dbgen.ProvisionerDaemon(t, coderdAPI.Database, database.ProvisionerDaemon{
Name: "provisioner-1",
CreatedAt: dbtime.Now().Add(1 * time.Second),
LastSeenAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(time.Hour), Valid: true}, // Stale interval can't be adjusted, keep online.
KeyID: codersdk.ProvisionerKeyUUIDBuiltIn,
Tags: database.StringMap{"owner": "", "scope": "organization", "foo": "bar"},
})
w1 := dbgen.Workspace(t, coderdAPI.Database, database.WorkspaceTable{
OwnerID: member.ID,
TemplateID: template.ID,
})
wb1ID := uuid.MustParse("00000000-0000-0000-dddd-000000000001")
job1 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{
WorkerID: uuid.NullUUID{UUID: pd1.ID, Valid: true},
Input: json.RawMessage(`{"workspace_build_id":"` + wb1ID.String() + `"}`),
CreatedAt: dbtime.Now().Add(2 * time.Second),
StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now(), Valid: true},
Tags: database.StringMap{"owner": "", "scope": "organization", "foo": "bar"},
})
dbgen.WorkspaceBuild(t, coderdAPI.Database, database.WorkspaceBuild{
ID: wb1ID,
JobID: job1.ID,
WorkspaceID: w1.ID,
TemplateVersionID: version.ID,
})
// Create a provisioner that completed a job previously and is offline.
pd2 := dbgen.ProvisionerDaemon(t, coderdAPI.Database, database.ProvisionerDaemon{
Name: "provisioner-2",
CreatedAt: dbtime.Now().Add(2 * time.Second),
LastSeenAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Hour), Valid: true},
KeyID: codersdk.ProvisionerKeyUUIDBuiltIn,
Tags: database.StringMap{"owner": "", "scope": "organization"},
})
w2 := dbgen.Workspace(t, coderdAPI.Database, database.WorkspaceTable{
OwnerID: member.ID,
TemplateID: template.ID,
})
wb2ID := uuid.MustParse("00000000-0000-0000-dddd-000000000002")
job2 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{
WorkerID: uuid.NullUUID{UUID: pd2.ID, Valid: true},
Input: json.RawMessage(`{"workspace_build_id":"` + wb2ID.String() + `"}`),
CreatedAt: dbtime.Now().Add(3 * time.Second),
StartedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-2 * time.Hour), Valid: true},
CompletedAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(-time.Hour), Valid: true},
Tags: database.StringMap{"owner": "", "scope": "organization"},
})
dbgen.WorkspaceBuild(t, coderdAPI.Database, database.WorkspaceBuild{
ID: wb2ID,
JobID: job2.ID,
WorkspaceID: w2.ID,
TemplateVersionID: version.ID,
})
// Create a pending job.
w3 := dbgen.Workspace(t, coderdAPI.Database, database.WorkspaceTable{
OwnerID: member.ID,
TemplateID: template.ID,
})
wb3ID := uuid.MustParse("00000000-0000-0000-dddd-000000000003")
job3 := dbgen.ProvisionerJob(t, db, coderdAPI.Pubsub, database.ProvisionerJob{
Input: json.RawMessage(`{"workspace_build_id":"` + wb3ID.String() + `"}`),
CreatedAt: dbtime.Now().Add(4 * time.Second),
Tags: database.StringMap{"owner": "", "scope": "organization"},
})
dbgen.WorkspaceBuild(t, coderdAPI.Database, database.WorkspaceBuild{
ID: wb3ID,
JobID: job3.ID,
WorkspaceID: w3.ID,
TemplateVersionID: version.ID,
})
// Create a provisioner that is idle.
pd3 := dbgen.ProvisionerDaemon(t, coderdAPI.Database, database.ProvisionerDaemon{
Name: "provisioner-3",
CreatedAt: dbtime.Now().Add(3 * time.Second),
LastSeenAt: sql.NullTime{Time: coderdAPI.Clock.Now().Add(time.Hour), Valid: true},
KeyID: codersdk.ProvisionerKeyUUIDBuiltIn,
Tags: database.StringMap{"owner": "", "scope": "organization"},
})
// Add more provisioners than the default limit.
var userDaemons []database.ProvisionerDaemon
for i := range 50 {
userDaemons = append(userDaemons, dbgen.ProvisionerDaemon(t, coderdAPI.Database, database.ProvisionerDaemon{
Name: "user-provisioner-" + strconv.Itoa(i),
CreatedAt: dbtime.Now().Add(3 * time.Second),
KeyID: codersdk.ProvisionerKeyUUIDUserAuth,
Tags: database.StringMap{"count": strconv.Itoa(i)},
}))
}
t.Run("Default limit", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})
owner := coderdtest.CreateFirstUser(t, client)
memberClient, _ := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID)
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, nil)
require.NoError(t, err)
require.Len(t, daemons, 50)
})
daemons, err := memberClient.ProvisionerDaemons(ctx)
t.Run("IDs", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{
IDs: []uuid.UUID{pd1.ID, pd2.ID},
})
require.NoError(t, err)
require.Len(t, daemons, 2)
require.Equal(t, pd1.ID, daemons[0].ID)
require.Equal(t, pd2.ID, daemons[1].ID)
})
t.Run("Tags", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{
Tags: map[string]string{"count": "1"},
})
require.NoError(t, err)
require.Len(t, daemons, 1)
require.Equal(t, userDaemons[1].ID, daemons[0].ID)
})
t.Run("Limit", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{
Limit: 1,
})
require.NoError(t, err)
require.Len(t, daemons, 1)
})
t.Run("Busy", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{
IDs: []uuid.UUID{pd1.ID},
})
require.NoError(t, err)
require.Len(t, daemons, 1)
// Verify status.
require.NotNil(t, daemons[0].Status)
require.Equal(t, codersdk.ProvisionerDaemonBusy, *daemons[0].Status)
require.NotNil(t, daemons[0].CurrentJob)
require.Nil(t, daemons[0].PreviousJob)
// Verify job.
require.Equal(t, job1.ID, daemons[0].CurrentJob.ID)
require.Equal(t, codersdk.ProvisionerJobRunning, daemons[0].CurrentJob.Status)
require.Equal(t, template.Name, daemons[0].CurrentJob.TemplateName)
require.Equal(t, template.DisplayName, daemons[0].CurrentJob.TemplateDisplayName)
require.Equal(t, template.Icon, daemons[0].CurrentJob.TemplateIcon)
})
t.Run("Offline", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{
IDs: []uuid.UUID{pd2.ID},
})
require.NoError(t, err)
require.Len(t, daemons, 1)
// Verify status.
require.NotNil(t, daemons[0].Status)
require.Equal(t, codersdk.ProvisionerDaemonOffline, *daemons[0].Status)
require.Nil(t, daemons[0].CurrentJob)
require.NotNil(t, daemons[0].PreviousJob)
// Verify job.
require.Equal(t, job2.ID, daemons[0].PreviousJob.ID)
require.Equal(t, codersdk.ProvisionerJobSucceeded, daemons[0].PreviousJob.Status)
require.Equal(t, template.Name, daemons[0].PreviousJob.TemplateName)
require.Equal(t, template.DisplayName, daemons[0].PreviousJob.TemplateDisplayName)
require.Equal(t, template.Icon, daemons[0].PreviousJob.TemplateIcon)
})
t.Run("Idle", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := templateAdminClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerDaemonsOptions{
IDs: []uuid.UUID{pd3.ID},
})
require.NoError(t, err)
require.Len(t, daemons, 1)
// Verify status.
require.NotNil(t, daemons[0].Status)
require.Equal(t, codersdk.ProvisionerDaemonIdle, *daemons[0].Status)
require.Nil(t, daemons[0].CurrentJob)
require.Nil(t, daemons[0].PreviousJob)
})
t.Run("MemberAllowed", func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitMedium)
daemons, err := memberClient.OrganizationProvisionerDaemons(ctx, owner.OrganizationID, nil)
require.NoError(t, err)
require.Len(t, daemons, 50)
})
}

View File

@ -316,21 +316,34 @@ func (c *Client) ProvisionerDaemons(ctx context.Context) ([]ProvisionerDaemon, e
return daemons, json.NewDecoder(res.Body).Decode(&daemons)
}
func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizationID uuid.UUID, tags map[string]string) ([]ProvisionerDaemon, error) {
baseURL := fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons", organizationID.String())
type OrganizationProvisionerDaemonsOptions struct {
Limit int
IDs []uuid.UUID
Tags map[string]string
}
queryParams := url.Values{}
tagsJSON, err := json.Marshal(tags)
if err != nil {
return nil, xerrors.Errorf("marshal tags: %w", err)
func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerDaemonsOptions) ([]ProvisionerDaemon, error) {
qp := url.Values{}
if opts != nil {
if opts.Limit > 0 {
qp.Add("limit", strconv.Itoa(opts.Limit))
}
if len(opts.IDs) > 0 {
qp.Add("ids", joinSliceStringer(opts.IDs))
}
if len(opts.Tags) > 0 {
tagsRaw, err := json.Marshal(opts.Tags)
if err != nil {
return nil, xerrors.Errorf("marshal tags: %w", err)
}
qp.Add("tags", string(tagsRaw))
}
}
queryParams.Add("tags", string(tagsJSON))
if len(queryParams) > 0 {
baseURL = fmt.Sprintf("%s?%s", baseURL, queryParams.Encode())
}
res, err := c.Request(ctx, http.MethodGet, baseURL, nil)
res, err := c.Request(ctx, http.MethodGet,
fmt.Sprintf("/api/v2/organizations/%s/provisionerdaemons?%s", organizationID.String(), qp.Encode()),
nil,
)
if err != nil {
return nil, xerrors.Errorf("execute request: %w", err)
}

View File

@ -18,8 +18,29 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi
| Name | In | Type | Required | Description |
|----------------|-------|--------------|----------|------------------------------------------------------------------------------------|
| `organization` | path | string(uuid) | true | Organization ID |
| `limit` | query | integer | false | Page limit |
| `ids` | query | array(uuid) | false | Filter results by job IDs |
| `status` | query | string | false | Filter results by status |
| `tags` | query | object | false | Provisioner tags to filter by (JSON of the form {'tag1':'value1','tag2':'value2'}) |
#### Enumerated Values
| Parameter | Value |
|-----------|-------------|
| `status` | `pending` |
| `status` | `running` |
| `status` | `succeeded` |
| `status` | `canceling` |
| `status` | `canceled` |
| `status` | `failed` |
| `status` | `unknown` |
| `status` | `pending` |
| `status` | `running` |
| `status` | `succeeded` |
| `status` | `canceling` |
| `status` | `canceled` |
| `status` | `failed` |
### Example responses
> 200 Response

View File

@ -990,7 +990,9 @@ func TestGetProvisionerDaemons(t *testing.T) {
require.NoError(t, err)
require.Len(t, allDaemons, 1)
daemonsAsFound, err := orgAdmin.OrganizationProvisionerDaemons(ctx, org.ID, tt.tagsToFilterBy)
daemonsAsFound, err := orgAdmin.OrganizationProvisionerDaemons(ctx, org.ID, &codersdk.OrganizationProvisionerDaemonsOptions{
Tags: tt.tagsToFilterBy,
})
if tt.expectToGetDaemon {
require.NoError(t, err)
require.Len(t, daemonsAsFound, 1)

View File

@ -1435,6 +1435,13 @@ export interface OrganizationMemberWithUserData extends OrganizationMember {
readonly global_roles: readonly SlimRole[];
}
// From codersdk/organizations.go
export interface OrganizationProvisionerDaemonsOptions {
readonly Limit: number;
readonly IDs: readonly string[];
readonly Tags: Record<string, string>;
}
// From codersdk/organizations.go
export interface OrganizationProvisionerJobsOptions {
readonly Limit: number;