From 88b85c56736aef5183cc9603653c78c526f85fe2 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Thu, 13 Feb 2025 15:36:48 +0000 Subject: [PATCH] feat(coderd): add support for provisioner job id and tag filter This change adds to new filters to the provisionerjobs endpoint, id (array) and tags (map). --- coderd/apidoc/docs.go | 16 ++++++++++++++ coderd/apidoc/swagger.json | 16 ++++++++++++++ coderd/database/dbmem/dbmem.go | 3 +++ coderd/database/queries.sql.go | 5 ++++- coderd/database/queries/provisionerjobs.sql | 1 + coderd/provisionerjobs.go | 18 ++++++++++++++++ coderd/provisionerjobs_test.go | 24 ++++++++++++++++++++- codersdk/organizations.go | 20 +++++++++++++++++ docs/reference/api/organizations.md | 12 ++++++----- site/src/api/typesGenerated.ts | 2 ++ 10 files changed, 110 insertions(+), 7 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2e48634c7de13..6f09a0482dbd1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -3055,6 +3055,16 @@ const docTemplate = `{ "name": "limit", "in": "query" }, + { + "type": "array", + "format": "uuid", + "items": { + "type": "string" + }, + "description": "Filter results by job IDs", + "name": "ids", + "in": "query" + }, { "enum": [ "pending", @@ -3075,6 +3085,12 @@ const docTemplate = `{ "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'})", + "name": "tags", + "in": "query" } ], "responses": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 0e03555da4720..db682394ca04a 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -2683,6 +2683,16 @@ "name": "limit", "in": "query" }, + { + "type": "array", + "format": "uuid", + "items": { + "type": "string" + }, + "description": "Filter results by job IDs", + "name": "ids", + "in": "query" + }, { "enum": [ "pending", @@ -2703,6 +2713,12 @@ "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'})", + "name": "tags", + "in": "query" } ], "responses": { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 21c40233718ef..780a180f1ff35 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -4170,6 +4170,9 @@ func (q *FakeQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePosition if len(arg.IDs) > 0 && !slices.Contains(arg.IDs, job.ID) { continue } + if len(arg.Tags) > 0 && !tagsSubset(job.Tags, arg.Tags) { + continue + } row := database.GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerRow{ ProvisionerJob: rowQP.ProvisionerJob, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 2d7fe83296deb..d8c2b3a77dacf 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6472,6 +6472,7 @@ WHERE ($1::uuid IS NULL OR pj.organization_id = $1) AND (COALESCE(array_length($2::uuid[], 1), 0) = 0 OR pj.id = ANY($2::uuid[])) AND (COALESCE(array_length($3::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY($3::provisioner_job_status[])) + AND ($4::tagset = 'null'::tagset OR provisioner_tagset_contains(pj.tags::tagset, $4::tagset)) GROUP BY pj.id, qp.queue_position, @@ -6486,13 +6487,14 @@ GROUP BY ORDER BY pj.created_at DESC LIMIT - $4::int + $5::int ` type GetProvisionerJobsByOrganizationAndStatusWithQueuePositionAndProvisionerParams struct { OrganizationID uuid.NullUUID `db:"organization_id" json:"organization_id"` IDs []uuid.UUID `db:"ids" json:"ids"` Status []ProvisionerJobStatus `db:"status" json:"status"` + Tags StringMap `db:"tags" json:"tags"` Limit sql.NullInt32 `db:"limit" json:"limit"` } @@ -6515,6 +6517,7 @@ func (q *sqlQuerier) GetProvisionerJobsByOrganizationAndStatusWithQueuePositionA arg.OrganizationID, pq.Array(arg.IDs), pq.Array(arg.Status), + arg.Tags, arg.Limit, ) if err != nil { diff --git a/coderd/database/queries/provisionerjobs.sql b/coderd/database/queries/provisionerjobs.sql index fedcc630a1687..9888fb11dfc3b 100644 --- a/coderd/database/queries/provisionerjobs.sql +++ b/coderd/database/queries/provisionerjobs.sql @@ -158,6 +158,7 @@ WHERE (sqlc.narg('organization_id')::uuid IS NULL OR pj.organization_id = @organization_id) AND (COALESCE(array_length(@ids::uuid[], 1), 0) = 0 OR pj.id = ANY(@ids::uuid[])) AND (COALESCE(array_length(@status::provisioner_job_status[], 1), 0) = 0 OR pj.job_status = ANY(@status::provisioner_job_status[])) + AND (@tags::tagset = 'null'::tagset OR provisioner_tagset_contains(pj.tags::tagset, @tags::tagset)) GROUP BY pj.id, qp.queue_position, diff --git a/coderd/provisionerjobs.go b/coderd/provisionerjobs.go index 492aa50eeb7f9..b12187e682efa 100644 --- a/coderd/provisionerjobs.go +++ b/coderd/provisionerjobs.go @@ -72,7 +72,9 @@ func (api *API) provisionerJob(rw http.ResponseWriter, r *http.Request) { // @Tags Organizations // @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.ProvisionerJob // @Router /organizations/{organization}/provisionerjobs [get] func (api *API) provisionerJobs(rw http.ResponseWriter, r *http.Request) { @@ -103,6 +105,10 @@ func (api *API) handleAuthAndFetchProvisionerJobs(rw http.ResponseWriter, r *htt p := httpapi.NewQueryParamParser() limit := p.PositiveInt32(qp, 50, "limit") status := p.Strings(qp, nil, "status") + if ids == nil { + 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{ @@ -112,11 +118,23 @@ func (api *API) handleAuthAndFetchProvisionerJobs(rw http.ResponseWriter, r *htt return nil, false } + 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 nil, false + } + } + 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}, IDs: ids, + Tags: tags, }) if err != nil { if httpapi.Is404Error(err) { diff --git a/coderd/provisionerjobs_test.go b/coderd/provisionerjobs_test.go index 1c832d6825c6f..6ec8959102fa5 100644 --- a/coderd/provisionerjobs_test.go +++ b/coderd/provisionerjobs_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "strconv" "testing" "time" @@ -65,9 +66,10 @@ func TestProvisionerJobs(t *testing.T) { }) // Add more jobs than the default limit. - for range 60 { + for i := range 60 { dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ OrganizationID: owner.OrganizationID, + Tags: database.StringMap{"count": strconv.Itoa(i)}, }) } @@ -132,6 +134,16 @@ func TestProvisionerJobs(t *testing.T) { require.Len(t, jobs, 50) }) + t.Run("IDs", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + jobs, err := templateAdminClient.OrganizationProvisionerJobs(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerJobsOptions{ + IDs: []uuid.UUID{workspace.LatestBuild.Job.ID, version.Job.ID}, + }) + require.NoError(t, err) + require.Len(t, jobs, 2) + }) + t.Run("Status", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) @@ -142,6 +154,16 @@ func TestProvisionerJobs(t *testing.T) { require.Len(t, jobs, 1) }) + t.Run("Tags", func(t *testing.T) { + t.Parallel() + ctx := testutil.Context(t, testutil.WaitMedium) + jobs, err := templateAdminClient.OrganizationProvisionerJobs(ctx, owner.OrganizationID, &codersdk.OrganizationProvisionerJobsOptions{ + Tags: map[string]string{"count": "1"}, + }) + require.NoError(t, err) + require.Len(t, jobs, 1) + }) + t.Run("Limit", func(t *testing.T) { t.Parallel() ctx := testutil.Context(t, testutil.WaitMedium) diff --git a/codersdk/organizations.go b/codersdk/organizations.go index a6bacd2798043..98afd98feda2a 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -346,7 +346,9 @@ func (c *Client) OrganizationProvisionerDaemons(ctx context.Context, organizatio type OrganizationProvisionerJobsOptions struct { Limit int + IDs []uuid.UUID Status []ProvisionerJobStatus + Tags map[string]string } func (c *Client) OrganizationProvisionerJobs(ctx context.Context, organizationID uuid.UUID, opts *OrganizationProvisionerJobsOptions) ([]ProvisionerJob, error) { @@ -355,9 +357,19 @@ func (c *Client) OrganizationProvisionerJobs(ctx context.Context, organizationID 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.Status) > 0 { qp.Add("status", joinSlice(opts.Status)) } + 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, @@ -401,6 +413,14 @@ func joinSlice[T ~string](s []T) string { return strings.Join(ss, ",") } +func joinSliceStringer[T fmt.Stringer](s []T) string { + var ss []string + for _, v := range s { + ss = append(ss, v.String()) + } + return strings.Join(ss, ",") +} + // CreateTemplateVersion processes source-code and optionally associates the version with a template. // Executing without a template is useful for validating source-code. func (c *Client) CreateTemplateVersion(ctx context.Context, organizationID uuid.UUID, req CreateTemplateVersionRequest) (TemplateVersion, error) { diff --git a/docs/reference/api/organizations.md b/docs/reference/api/organizations.md index 08fceb2e29d82..8c49f33e31ce3 100644 --- a/docs/reference/api/organizations.md +++ b/docs/reference/api/organizations.md @@ -359,11 +359,13 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/provisi ### Parameters -| Name | In | Type | Required | Description | -|----------------|-------|--------------|----------|--------------------------| -| `organization` | path | string(uuid) | true | Organization ID | -| `limit` | query | integer | false | Page limit | -| `status` | query | string | false | Filter results by status | +| 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 diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 09541c9767b89..50b45ccd4d22f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1438,7 +1438,9 @@ export interface OrganizationMemberWithUserData extends OrganizationMember { // From codersdk/organizations.go export interface OrganizationProvisionerJobsOptions { readonly Limit: number; + readonly IDs: readonly string[]; readonly Status: readonly ProvisionerJobStatus[]; + readonly Tags: Record; } // From codersdk/idpsync.go