diff --git a/coderd/database/databasefake/databasefake.go b/coderd/database/databasefake/databasefake.go index 78f85e71c597c..6626aa9ec6323 100644 --- a/coderd/database/databasefake/databasefake.go +++ b/coderd/database/databasefake/databasefake.go @@ -333,6 +333,18 @@ func (q *fakeQuerier) GetWorkspacesWithFilter(_ context.Context, arg database.Ge if arg.Name != "" && !strings.Contains(workspace.Name, arg.Name) { continue } + if len(arg.TemplateIds) > 0 { + match := false + for _, id := range arg.TemplateIds { + if workspace.TemplateID == id { + match = true + break + } + } + if !match { + continue + } + } workspaces = append(workspaces, workspace) } @@ -761,6 +773,26 @@ func (q *fakeQuerier) UpdateTemplateMetaByID(_ context.Context, arg database.Upd return sql.ErrNoRows } +func (q *fakeQuerier) GetTemplatesByName(_ context.Context, arg database.GetTemplatesByNameParams) ([]database.Template, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + var templates []database.Template + for _, template := range q.templates { + if !strings.EqualFold(template.Name, arg.Name) { + continue + } + if template.Deleted != arg.Deleted { + continue + } + templates = append(templates, template) + } + if len(templates) > 0 { + return templates, nil + } + + return nil, sql.ErrNoRows +} + func (q *fakeQuerier) GetTemplateVersionsByTemplateID(_ context.Context, arg database.GetTemplateVersionsByTemplateIDParams) (version []database.TemplateVersion, err error) { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 59e8f4577ef8e..0ace0fb587a45 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -54,6 +54,7 @@ type querier interface { GetTemplateVersionByTemplateIDAndName(ctx context.Context, arg GetTemplateVersionByTemplateIDAndNameParams) (TemplateVersion, error) GetTemplateVersionsByTemplateID(ctx context.Context, arg GetTemplateVersionsByTemplateIDParams) ([]TemplateVersion, error) GetTemplatesByIDs(ctx context.Context, ids []uuid.UUID) ([]Template, error) + GetTemplatesByName(ctx context.Context, arg GetTemplatesByNameParams) ([]Template, error) GetTemplatesByOrganization(ctx context.Context, arg GetTemplatesByOrganizationParams) ([]Template, error) GetUserByEmailOrUsername(ctx context.Context, arg GetUserByEmailOrUsernameParams) (User, error) GetUserByID(ctx context.Context, id uuid.UUID) (User, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 06e5f61105269..447102d8f8114 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -1713,6 +1713,56 @@ func (q *sqlQuerier) GetTemplatesByIDs(ctx context.Context, ids []uuid.UUID) ([] return items, nil } +const getTemplatesByName = `-- name: GetTemplatesByName :many +SELECT + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval +FROM + templates +WHERE + deleted = $1 + AND LOWER("name") = LOWER($2) +` + +type GetTemplatesByNameParams struct { + Deleted bool `db:"deleted" json:"deleted"` + Name string `db:"name" json:"name"` +} + +func (q *sqlQuerier) GetTemplatesByName(ctx context.Context, arg GetTemplatesByNameParams) ([]Template, error) { + rows, err := q.db.QueryContext(ctx, getTemplatesByName, arg.Deleted, arg.Name) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Template + for rows.Next() { + var i Template + if err := rows.Scan( + &i.ID, + &i.CreatedAt, + &i.UpdatedAt, + &i.OrganizationID, + &i.Deleted, + &i.Name, + &i.Provisioner, + &i.ActiveVersionID, + &i.Description, + &i.MaxTtl, + &i.MinAutostartInterval, + ); 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 getTemplatesByOrganization = `-- name: GetTemplatesByOrganization :many SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, max_ttl, min_autostart_interval @@ -3707,19 +3757,26 @@ WHERE owner_id = $3 ELSE true END + -- Filter by template_ids + AND CASE + WHEN array_length($4 :: uuid[], 1) > 0 THEN + template_id = ANY($4) + ELSE true + END -- Filter by name, matching on substring AND CASE - WHEN $4 :: text != '' THEN - LOWER(name) LIKE '%' || LOWER($4) || '%' + WHEN $5 :: text != '' THEN + LOWER(name) LIKE '%' || LOWER($5) || '%' ELSE true END ` type GetWorkspacesWithFilterParams struct { - Deleted bool `db:"deleted" json:"deleted"` - OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - Name string `db:"name" json:"name"` + Deleted bool `db:"deleted" json:"deleted"` + OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + TemplateIds []uuid.UUID `db:"template_ids" json:"template_ids"` + Name string `db:"name" json:"name"` } func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspacesWithFilterParams) ([]Workspace, error) { @@ -3727,6 +3784,7 @@ func (q *sqlQuerier) GetWorkspacesWithFilter(ctx context.Context, arg GetWorkspa arg.Deleted, arg.OrganizationID, arg.OwnerID, + pq.Array(arg.TemplateIds), arg.Name, ) if err != nil { diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index ffc0b9dabfb38..52564413f9980 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -28,6 +28,15 @@ WHERE LIMIT 1; +-- name: GetTemplatesByName :many +SELECT + * +FROM + templates +WHERE + deleted = @deleted + AND LOWER("name") = LOWER(@name); + -- name: GetTemplatesByOrganization :many SELECT * diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 8c17c323b091d..0d3151c8c46d3 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -28,6 +28,12 @@ WHERE owner_id = @owner_id ELSE true END + -- Filter by template_ids + AND CASE + WHEN array_length(@template_ids :: uuid[], 1) > 0 THEN + template_id = ANY(@template_ids) + ELSE true + END -- Filter by name, matching on substring AND CASE WHEN @name :: text != '' THEN diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 079f880ed44f3..f4a233f6034a9 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -103,41 +103,45 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) { // Optional filters with query params func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) { apiKey := httpmw.APIKey(r) + filter := database.GetWorkspacesWithFilterParams{Deleted: false} - // Empty strings mean no filter orgFilter := r.URL.Query().Get("organization_id") - ownerFilter := r.URL.Query().Get("owner") - nameFilter := r.URL.Query().Get("name") - - filter := database.GetWorkspacesWithFilterParams{Deleted: false} if orgFilter != "" { orgID, err := uuid.Parse(orgFilter) if err == nil { filter.OrganizationID = orgID } } + + ownerFilter := r.URL.Query().Get("owner") if ownerFilter == "me" { filter.OwnerID = apiKey.UserID } else if ownerFilter != "" { - userID, err := uuid.Parse(ownerFilter) - if err != nil { - // Maybe it's a username - user, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ - // Why not just accept 1 arg and use it for both in the sql? - Username: ownerFilter, - Email: ownerFilter, - }) - if err == nil { - filter.OwnerID = user.ID - } - } else { - filter.OwnerID = userID + user, err := api.Database.GetUserByEmailOrUsername(r.Context(), database.GetUserByEmailOrUsernameParams{ + Username: ownerFilter, + }) + if err == nil { + filter.OwnerID = user.ID } } + + nameFilter := r.URL.Query().Get("name") if nameFilter != "" { filter.Name = nameFilter } + templateFilter := r.URL.Query().Get("template") + if templateFilter != "" { + ts, err := api.Database.GetTemplatesByName(r.Context(), database.GetTemplatesByNameParams{ + Name: templateFilter, + }) + if err == nil { + for _, t := range ts { + filter.TemplateIds = append(filter.TemplateIds, t.ID) + } + } + } + workspaces, err := api.Database.GetWorkspacesWithFilter(r.Context(), filter) if err != nil { httpapi.Write(rw, http.StatusInternalServerError, httpapi.Response{ diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 73fed89c80c79..9253f8f2548f6 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -339,6 +339,30 @@ func TestWorkspaceFilter(t *testing.T) { require.NoError(t, err) require.Len(t, ws, 0) }) + t.Run("Template", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerD: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + template2 := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template2.ID) + + // empty + ws, err := client.Workspaces(context.Background(), codersdk.WorkspaceFilter{}) + require.NoError(t, err) + require.Len(t, ws, 2) + + // single template + ws, err = client.Workspaces(context.Background(), codersdk.WorkspaceFilter{ + Template: template.Name, + }) + require.NoError(t, err) + require.Len(t, ws, 1) + require.Equal(t, workspace.ID, ws[0].ID) + }) } func TestPostWorkspaceBuild(t *testing.T) { diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 67db13a422459..cd4dd3d001765 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -218,9 +218,12 @@ func (c *Client) PutExtendWorkspace(ctx context.Context, id uuid.UUID, req PutEx type WorkspaceFilter struct { OrganizationID uuid.UUID `json:"organization_id,omitempty"` - // Owner can be a user_id (uuid), "me", or a username + // Owner can be "me" or a username Owner string `json:"owner,omitempty"` - Name string `json:"name,omitempty"` + // Template is a template name + Template string `json:"template,omitempty"` + // Name will return partial matches + Name string `json:"name,omitempty"` } // asRequestOption returns a function that can be used in (*Client).Request. @@ -237,6 +240,9 @@ func (f WorkspaceFilter) asRequestOption() requestOption { if f.Name != "" { q.Set("name", f.Name) } + if f.Template != "" { + q.Set("template", f.Template) + } r.URL.RawQuery = q.Encode() } } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index f565f6f4e50ff..e4c992b261bea 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -463,7 +463,7 @@ export interface WorkspaceBuildsRequest extends Pagination { readonly WorkspaceID: string } -// From codersdk/workspaces.go:261:6 +// From codersdk/workspaces.go:267:6 export interface WorkspaceByOwnerAndNameParams { readonly include_deleted?: boolean } @@ -472,6 +472,7 @@ export interface WorkspaceByOwnerAndNameParams { export interface WorkspaceFilter { readonly organization_id?: string readonly owner?: string + readonly template?: string readonly name?: string }