diff --git a/cli/testdata/coder_provisioner_list_--output_json.golden b/cli/testdata/coder_provisioner_list_--output_json.golden index d6983d11e5fa3..168e690f0b33a 100644 --- a/cli/testdata/coder_provisioner_list_--output_json.golden +++ b/cli/testdata/coder_provisioner_list_--output_json.golden @@ -21,7 +21,7 @@ "previous_job": { "id": "======[workspace build job ID]======", "status": "succeeded", - "template_name": "", + "template_name": "test-template", "template_icon": "", "template_display_name": "" }, diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 6f09a0482dbd1..7620e1d72ea8c 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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'})", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index db682394ca04a..4c43e5f573d2e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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'})", diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 780a180f1ff35..6bace66f538fd 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -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 } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5f9856028b985..31c4a18a5808a 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index d8c2b3a77dacf..c19f6922e5117 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 } diff --git a/coderd/database/queries/provisionerdaemons.sql b/coderd/database/queries/provisionerdaemons.sql index b003153ee939d..2aaf23ec0d6cf 100644 --- a/coderd/database/queries/provisionerdaemons.sql +++ b/coderd/database/queries/provisionerdaemons.sql @@ -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 diff --git a/coderd/provisionerdaemons.go b/coderd/provisionerdaemons.go index bf4dfb6c4d7dd..e701771770091 100644 --- a/coderd/provisionerdaemons.go +++ b/coderd/provisionerdaemons.go @@ -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, } } diff --git a/coderd/provisionerdaemons_test.go b/coderd/provisionerdaemons_test.go index 243a24add021f..6496b4dd57e0a 100644 --- a/coderd/provisionerdaemons_test.go +++ b/coderd/provisionerdaemons_test.go @@ -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) + }) + 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) + }) - daemons, err := memberClient.ProvisionerDaemons(ctx) + 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) }) } diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 98afd98feda2a..781baaaa5d5d6 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -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()) - - queryParams := url.Values{} - tagsJSON, err := json.Marshal(tags) - if err != nil { - return nil, xerrors.Errorf("marshal tags: %w", err) - } +type OrganizationProvisionerDaemonsOptions struct { + Limit int + IDs []uuid.UUID + Tags map[string]string +} - queryParams.Add("tags", string(tagsJSON)) - if len(queryParams) > 0 { - baseURL = fmt.Sprintf("%s?%s", baseURL, queryParams.Encode()) +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)) + } } - 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) } diff --git a/docs/reference/api/provisioning.md b/docs/reference/api/provisioning.md index a8f7fd7e83214..1d910e4bc045e 100644 --- a/docs/reference/api/provisioning.md +++ b/docs/reference/api/provisioning.md @@ -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 diff --git a/enterprise/coderd/provisionerdaemons_test.go b/enterprise/coderd/provisionerdaemons_test.go index bcdb75c6a50fc..20d784467591f 100644 --- a/enterprise/coderd/provisionerdaemons_test.go +++ b/enterprise/coderd/provisionerdaemons_test.go @@ -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) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 50b45ccd4d22f..5f4f41a6e6de2 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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; +} + // From codersdk/organizations.go export interface OrganizationProvisionerJobsOptions { readonly Limit: number;