diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index dd18e313ce..e9c853bbde 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8739,6 +8739,55 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. continue } + if len(arg.HasParam) > 0 || len(arg.ParamNames) > 0 { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) + if err != nil { + return nil, xerrors.Errorf("get latest build: %w", err) + } + + params := make([]database.WorkspaceBuildParameter, 0) + for _, param := range q.workspaceBuildParameters { + if param.WorkspaceBuildID != build.ID { + continue + } + params = append(params, param) + } + + var innerErr error + index := slices.IndexFunc(params, func(buildParam database.WorkspaceBuildParameter) bool { + // If hasParam matches, then we are done. This is a good match. + if slices.ContainsFunc(arg.HasParam, func(name string) bool { + return strings.EqualFold(buildParam.Name, name) + }) { + return true + } + + // Check name + value + match := false + for i := range arg.ParamNames { + matchName := arg.ParamNames[i] + if !strings.EqualFold(matchName, buildParam.Name) { + continue + } + + matchValue := arg.ParamValues[i] + if !strings.EqualFold(matchValue, buildParam.Value) { + continue + } + match = true + break + } + + return match + }) + if innerErr != nil { + return nil, xerrors.Errorf("error searching workspace build params: %w", innerErr) + } + if index < 0 { + continue + } + } + if arg.OwnerUsername != "" { owner, err := q.getUserByIDNoLock(workspace.OwnerID) if err == nil && !strings.EqualFold(arg.OwnerUsername, owner.Username) { diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 0879aca441..40c953375d 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -213,9 +213,12 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa // The name comment is for metric tracking query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s", filtered) rows, err := q.db.QueryContext(ctx, query, + pq.Array(arg.ParamNames), + pq.Array(arg.ParamValues), arg.Deleted, arg.Status, arg.OwnerID, + pq.Array(arg.HasParam), arg.OwnerUsername, arg.TemplateName, pq.Array(arg.TemplateIDs), diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 87d5d77711..8a7e3b2072 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -277,6 +277,9 @@ type sqlcQuerier interface { GetWorkspaceResourcesByJobIDs(ctx context.Context, ids []uuid.UUID) ([]WorkspaceResource, error) GetWorkspaceResourcesCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceResource, error) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Context, templateIds []uuid.UUID) ([]GetWorkspaceUniqueOwnerCountByTemplateIDsRow, error) + // build_params is used to filter by build parameters if present. + // It has to be a CTE because the set returning function 'unnest' cannot + // be used in a WHERE clause. GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) GetWorkspacesEligibleForTransition(ctx context.Context, now time.Time) ([]Workspace, error) InsertAPIKey(ctx context.Context, arg InsertAPIKeyParams) (APIKey, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 5852308025..2112b05133 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -12162,7 +12162,13 @@ func (q *sqlQuerier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Conte } const getWorkspaces = `-- name: GetWorkspaces :many -WITH filtered_workspaces AS ( +WITH +build_params AS ( +SELECT + LOWER(unnest($1 :: text[])) AS name, + LOWER(unnest($2 :: text[])) AS value +), +filtered_workspaces AS ( SELECT workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, COALESCE(template.name, 'unknown') as template_name, @@ -12181,6 +12187,7 @@ ON workspaces.owner_id = users.id LEFT JOIN LATERAL ( SELECT + workspace_builds.id, workspace_builds.transition, workspace_builds.template_version_id, template_versions.name AS template_version_name, @@ -12218,32 +12225,32 @@ LEFT JOIN LATERAL ( ) template ON true WHERE -- Optionally include deleted workspaces - workspaces.deleted = $1 + workspaces.deleted = $3 AND CASE - WHEN $2 :: text != '' THEN + WHEN $4 :: text != '' THEN CASE -- Some workspace specific status refer to the transition -- type. By default, the standard provisioner job status -- search strings are supported. -- 'running' states - WHEN $2 = 'starting' THEN + WHEN $4 = 'starting' THEN latest_build.job_status = 'running'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition - WHEN $2 = 'stopping' THEN + WHEN $4 = 'stopping' THEN latest_build.job_status = 'running'::provisioner_job_status AND latest_build.transition = 'stop'::workspace_transition - WHEN $2 = 'deleting' THEN + WHEN $4 = 'deleting' THEN latest_build.job_status = 'running' AND latest_build.transition = 'delete'::workspace_transition -- 'succeeded' states - WHEN $2 = 'deleted' THEN + WHEN $4 = 'deleted' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'delete'::workspace_transition - WHEN $2 = 'stopped' THEN + WHEN $4 = 'stopped' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'stop'::workspace_transition - WHEN $2 = 'started' THEN + WHEN $4 = 'started' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition @@ -12251,13 +12258,13 @@ WHERE -- differ. A workspace is "running" if the job is "succeeded" and -- the transition is "start". This is because a workspace starts -- running when a job is complete. - WHEN $2 = 'running' THEN + WHEN $4 = 'running' THEN latest_build.job_status = 'succeeded'::provisioner_job_status AND latest_build.transition = 'start'::workspace_transition - WHEN $2 != '' THEN + WHEN $4 != '' THEN -- By default just match the job status exactly - latest_build.job_status = $2::provisioner_job_status + latest_build.job_status = $4::provisioner_job_status ELSE true END @@ -12265,46 +12272,80 @@ WHERE END -- Filter by owner_id AND CASE - WHEN $3 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN - workspaces.owner_id = $3 + WHEN $5 :: uuid != '00000000-0000-0000-0000-000000000000'::uuid THEN + workspaces.owner_id = $5 ELSE true END + -- Filter by build parameter + -- @has_param will match any build that includes the parameter. + AND CASE WHEN array_length($6 :: text[], 1) > 0 THEN + EXISTS ( + SELECT + 1 + FROM + workspace_build_parameters + WHERE + workspace_build_parameters.workspace_build_id = latest_build.id AND + -- ILIKE is case insensitive + workspace_build_parameters.name ILIKE ANY($6) + ) + ELSE true + END + -- @param_value will match param name an value. + -- requires 2 arrays, @param_names and @param_values to be passed in. + -- Array index must match between the 2 arrays for name=value + AND CASE WHEN array_length($1 :: text[], 1) > 0 THEN + EXISTS ( + SELECT + 1 + FROM + workspace_build_parameters + INNER JOIN + build_params + ON + LOWER(workspace_build_parameters.name) = build_params.name AND + LOWER(workspace_build_parameters.value) = build_params.value AND + workspace_build_parameters.workspace_build_id = latest_build.id + ) + ELSE true + END + -- Filter by owner_name AND CASE - WHEN $4 :: text != '' THEN - workspaces.owner_id = (SELECT id FROM users WHERE lower(username) = lower($4) AND deleted = false) + WHEN $7 :: text != '' THEN + workspaces.owner_id = (SELECT id FROM users WHERE lower(username) = lower($7) AND deleted = false) ELSE true END -- Filter by template_name -- There can be more than 1 template with the same name across organizations. -- Use the organization filter to restrict to 1 org if needed. AND CASE - WHEN $5 :: text != '' THEN - workspaces.template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($5) AND deleted = false) + WHEN $8 :: text != '' THEN + workspaces.template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($8) AND deleted = false) ELSE true END -- Filter by template_ids AND CASE - WHEN array_length($6 :: uuid[], 1) > 0 THEN - workspaces.template_id = ANY($6) + WHEN array_length($9 :: uuid[], 1) > 0 THEN + workspaces.template_id = ANY($9) ELSE true END -- Filter by workspace_ids AND CASE - WHEN array_length($7 :: uuid[], 1) > 0 THEN - workspaces.id = ANY($7) + WHEN array_length($10 :: uuid[], 1) > 0 THEN + workspaces.id = ANY($10) ELSE true END -- Filter by name, matching on substring AND CASE - WHEN $8 :: text != '' THEN - workspaces.name ILIKE '%' || $8 || '%' + WHEN $11 :: text != '' THEN + workspaces.name ILIKE '%' || $11 || '%' ELSE true END -- Filter by agent status -- has-agent: is only applicable for workspaces in "start" transition. Stopped and deleted workspaces don't have agents. AND CASE - WHEN $9 :: text != '' THEN + WHEN $12 :: text != '' THEN ( SELECT COUNT(*) FROM @@ -12316,7 +12357,7 @@ WHERE WHERE workspace_resources.job_id = latest_build.provisioner_job_id AND latest_build.transition = 'start'::workspace_transition AND - $9 = ( + $12 = ( CASE WHEN workspace_agents.first_connected_at IS NULL THEN CASE @@ -12327,7 +12368,7 @@ WHERE END WHEN workspace_agents.disconnected_at > workspace_agents.last_connected_at THEN 'disconnected' - WHEN NOW() - workspace_agents.last_connected_at > INTERVAL '1 second' * $10 :: bigint THEN + WHEN NOW() - workspace_agents.last_connected_at > INTERVAL '1 second' * $13 :: bigint THEN 'disconnected' WHEN workspace_agents.last_connected_at IS NOT NULL THEN 'connected' @@ -12340,24 +12381,24 @@ WHERE END -- Filter by dormant workspaces. AND CASE - WHEN $11 :: boolean != 'false' THEN + WHEN $14 :: boolean != 'false' THEN dormant_at IS NOT NULL ELSE true END -- Filter by last_used AND CASE - WHEN $12 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN - workspaces.last_used_at <= $12 + WHEN $15 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at <= $15 ELSE true END AND CASE - WHEN $13 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN - workspaces.last_used_at >= $13 + WHEN $16 :: timestamp with time zone > '0001-01-01 00:00:00Z' THEN + workspaces.last_used_at >= $16 ELSE true END AND CASE - WHEN $14 :: boolean IS NOT NULL THEN - (latest_build.template_version_id = template.active_version_id) = $14 :: boolean + WHEN $17 :: boolean IS NOT NULL THEN + (latest_build.template_version_id = template.active_version_id) = $17 :: boolean ELSE true END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces @@ -12369,7 +12410,7 @@ WHERE filtered_workspaces fw ORDER BY -- To ensure that 'favorite' workspaces show up first in the list only for their owner. - CASE WHEN owner_id = $15 AND favorite THEN 0 ELSE 1 END ASC, + CASE WHEN owner_id = $18 AND favorite THEN 0 ELSE 1 END ASC, (latest_build_completed_at IS NOT NULL AND latest_build_canceled_at IS NULL AND latest_build_error IS NULL AND @@ -12378,11 +12419,11 @@ WHERE LOWER(name) ASC LIMIT CASE - WHEN $17 :: integer > 0 THEN - $17 + WHEN $20 :: integer > 0 THEN + $20 END OFFSET - $16 + $19 ), filtered_workspaces_order_with_summary AS ( SELECT fwo.id, fwo.created_at, fwo.updated_at, fwo.owner_id, fwo.organization_id, fwo.template_id, fwo.deleted, fwo.name, fwo.autostart_schedule, fwo.ttl, fwo.last_used_at, fwo.dormant_at, fwo.deleting_at, fwo.automatic_updates, fwo.favorite, fwo.template_name, fwo.template_version_id, fwo.template_version_name, fwo.username, fwo.latest_build_completed_at, fwo.latest_build_canceled_at, fwo.latest_build_error, fwo.latest_build_transition @@ -12417,7 +12458,7 @@ WHERE '', -- latest_build_error 'start'::workspace_transition -- latest_build_transition WHERE - $18 :: boolean = true + $21 :: boolean = true ), total_count AS ( SELECT count(*) AS count @@ -12434,9 +12475,12 @@ CROSS JOIN ` type GetWorkspacesParams struct { + ParamNames []string `db:"param_names" json:"param_names"` + ParamValues []string `db:"param_values" json:"param_values"` Deleted bool `db:"deleted" json:"deleted"` Status string `db:"status" json:"status"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + HasParam []string `db:"has_param" json:"has_param"` OwnerUsername string `db:"owner_username" json:"owner_username"` TemplateName string `db:"template_name" json:"template_name"` TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` @@ -12481,11 +12525,17 @@ type GetWorkspacesRow struct { Count int64 `db:"count" json:"count"` } +// build_params is used to filter by build parameters if present. +// It has to be a CTE because the set returning function 'unnest' cannot +// be used in a WHERE clause. func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]GetWorkspacesRow, error) { rows, err := q.db.QueryContext(ctx, getWorkspaces, + pq.Array(arg.ParamNames), + pq.Array(arg.ParamValues), arg.Deleted, arg.Status, arg.OwnerID, + pq.Array(arg.HasParam), arg.OwnerUsername, arg.TemplateName, pq.Array(arg.TemplateIDs), diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 01c86cb41e..767280634f 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -77,7 +77,16 @@ WHERE ); -- name: GetWorkspaces :many -WITH filtered_workspaces AS ( +WITH +-- build_params is used to filter by build parameters if present. +-- It has to be a CTE because the set returning function 'unnest' cannot +-- be used in a WHERE clause. +build_params AS ( +SELECT + LOWER(unnest(@param_names :: text[])) AS name, + LOWER(unnest(@param_values :: text[])) AS value +), +filtered_workspaces AS ( SELECT workspaces.*, COALESCE(template.name, 'unknown') as template_name, @@ -96,6 +105,7 @@ ON workspaces.owner_id = users.id LEFT JOIN LATERAL ( SELECT + workspace_builds.id, workspace_builds.transition, workspace_builds.template_version_id, template_versions.name AS template_version_name, @@ -184,6 +194,40 @@ WHERE workspaces.owner_id = @owner_id ELSE true END + -- Filter by build parameter + -- @has_param will match any build that includes the parameter. + AND CASE WHEN array_length(@has_param :: text[], 1) > 0 THEN + EXISTS ( + SELECT + 1 + FROM + workspace_build_parameters + WHERE + workspace_build_parameters.workspace_build_id = latest_build.id AND + -- ILIKE is case insensitive + workspace_build_parameters.name ILIKE ANY(@has_param) + ) + ELSE true + END + -- @param_value will match param name an value. + -- requires 2 arrays, @param_names and @param_values to be passed in. + -- Array index must match between the 2 arrays for name=value + AND CASE WHEN array_length(@param_names :: text[], 1) > 0 THEN + EXISTS ( + SELECT + 1 + FROM + workspace_build_parameters + INNER JOIN + build_params + ON + LOWER(workspace_build_parameters.name) = build_params.name AND + LOWER(workspace_build_parameters.value) = build_params.value AND + workspace_build_parameters.workspace_build_id = latest_build.id + ) + ELSE true + END + -- Filter by owner_name AND CASE WHEN @owner_username :: text != '' THEN diff --git a/coderd/httpapi/queryparams.go b/coderd/httpapi/queryparams.go index 6d6f159257..77b58c8ae0 100644 --- a/coderd/httpapi/queryparams.go +++ b/coderd/httpapi/queryparams.go @@ -1,6 +1,7 @@ package httpapi import ( + "errors" "fmt" "net/url" "strconv" @@ -268,18 +269,18 @@ func ParseCustomList[T any](parser *QueryParamParser, vals url.Values, def []T, allTerms = append(allTerms, terms...) } - var badValues []string + var badErrors error var output []T for _, s := range allTerms { good, err := parseFunc(s) if err != nil { - badValues = append(badValues, s) + badErrors = errors.Join(badErrors, err) continue } output = append(output, good) } - if len(badValues) > 0 { - return []T{}, xerrors.Errorf("%s", strings.Join(badValues, ",")) + if badErrors != nil { + return []T{}, badErrors } return output, nil diff --git a/coderd/httpapi/queryparams_test.go b/coderd/httpapi/queryparams_test.go index 3c4f938cce..8e92b2b267 100644 --- a/coderd/httpapi/queryparams_test.go +++ b/coderd/httpapi/queryparams_test.go @@ -388,7 +388,7 @@ func TestParseQueryParams(t *testing.T) { Value: "6c8ef17d-5dd8-4b92-bac9-41944f90f237,bogus", Expected: []uuid.UUID{}, Default: []uuid.UUID{}, - ExpectedErrorContains: "bogus", + ExpectedErrorContains: "invalid UUID length", }, { QueryParam: "multiple_keys", diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 7173c53387..cef971a731 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -121,6 +121,40 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT Valid: values.Has("outdated"), } + type paramMatch struct { + name string + value *string + } + // parameter matching takes the form of: + // `param:[=]` + // If the value is omitted, then we match on the presence of the parameter. + // If the value is provided, then we match on the parameter and value. + params := httpapi.ParseCustomList(parser, values, []paramMatch{}, "param", func(v string) (paramMatch, error) { + // Ignore excess spaces + v = strings.TrimSpace(v) + parts := strings.Split(v, "=") + if len(parts) == 1 { + // Only match on the presence of the parameter + return paramMatch{name: parts[0], value: nil}, nil + } + if len(parts) == 2 { + if parts[1] == "" { + return paramMatch{}, xerrors.Errorf("query element %q has an empty value. omit the '=' to match just on the parameter name", v) + } + // Match on the parameter and value + return paramMatch{name: parts[0], value: &parts[1]}, nil + } + return paramMatch{}, xerrors.Errorf("query element %q can only contain 1 '='", v) + }) + for _, p := range params { + if p.value == nil { + filter.HasParam = append(filter.HasParam, p.name) + continue + } + filter.ParamNames = append(filter.ParamNames, p.name) + filter.ParamValues = append(filter.ParamValues, *p.value) + } + parser.ErrorExcessParams(values) return filter, parser.Errors } diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index b07e42438b..45f6de2d8b 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -137,7 +137,74 @@ func TestSearchWorkspace(t *testing.T) { }, }, }, + { + Name: "ParamName", + Query: "param:foo", + Expected: database.GetWorkspacesParams{ + HasParam: []string{"foo"}, + }, + }, + { + Name: "MultipleParamNames", + Query: "param:foo param:bar param:baz", + Expected: database.GetWorkspacesParams{ + HasParam: []string{"foo", "bar", "baz"}, + }, + }, + { + Name: "ParamValue", + Query: "param:foo=bar", + Expected: database.GetWorkspacesParams{ + ParamNames: []string{"foo"}, + ParamValues: []string{"bar"}, + }, + }, + { + Name: "QuotedParamValue", + Query: `param:"image=ghcr.io/coder/coder-preview:main"`, + Expected: database.GetWorkspacesParams{ + ParamNames: []string{"image"}, + ParamValues: []string{"ghcr.io/coder/coder-preview:main"}, + }, + }, + { + Name: "MultipleParamValues", + Query: "param:foo=bar param:fuzz=buzz", + Expected: database.GetWorkspacesParams{ + ParamNames: []string{"foo", "fuzz"}, + ParamValues: []string{"bar", "buzz"}, + }, + }, + { + Name: "MixedParams", + Query: "param:dot param:foo=bar param:fuzz=buzz param:tot", + Expected: database.GetWorkspacesParams{ + HasParam: []string{"dot", "tot"}, + ParamNames: []string{"foo", "fuzz"}, + ParamValues: []string{"bar", "buzz"}, + }, + }, + { + Name: "ParamSpaces", + Query: `param:" dot " param:" foo=bar "`, + Expected: database.GetWorkspacesParams{ + HasParam: []string{"dot"}, + ParamNames: []string{"foo"}, + ParamValues: []string{"bar"}, + }, + }, + // Failures + { + Name: "ParamExcessValue", + Query: "param:foo=bar=baz", + ExpectedErrorContains: "can only contain 1 '='", + }, + { + Name: "ParamNoValue", + Query: "param:foo=", + ExpectedErrorContains: "omit the '=' to match", + }, { Name: "NoPrefix", Query: `:foo`, @@ -163,6 +230,11 @@ func TestSearchWorkspace(t *testing.T) { Query: `foo:bar`, ExpectedErrorContains: `"foo" is not a valid query param`, }, + { + Name: "ParamExtraColons", + Query: "param:foo:value", + ExpectedErrorContains: "can only contain 1 ':'", + }, } for _, c := range testCases { @@ -182,6 +254,10 @@ func TestSearchWorkspace(t *testing.T) { // nil slice vs 0 len slice is equivalent for our purposes. c.Expected.WorkspaceIds = values.WorkspaceIds } + if len(c.Expected.HasParam) == len(values.HasParam) { + // nil slice vs 0 len slice is equivalent for our purposes. + c.Expected.HasParam = values.HasParam + } assert.Len(t, errs, 0, "expected no error") assert.Equal(t, c.Expected, values, "expected values") } diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 0f1569a331..f16d40f072 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1322,6 +1322,20 @@ func TestWorkspaceFilter(t *testing.T) { func TestWorkspaceFilterManual(t *testing.T) { t.Parallel() + expectIDs := func(t *testing.T, exp []codersdk.Workspace, got []codersdk.Workspace) { + t.Helper() + expIDs := make([]uuid.UUID, 0, len(exp)) + for _, e := range exp { + expIDs = append(expIDs, e.ID) + } + + gotIDs := make([]uuid.UUID, 0, len(got)) + for _, g := range got { + gotIDs = append(gotIDs, g.ID) + } + require.ElementsMatchf(t, expIDs, gotIDs, "expected IDs") + } + t.Run("Name", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) @@ -1593,7 +1607,6 @@ func TestWorkspaceFilterManual(t *testing.T) { return workspaces.Count == 1 }, testutil.IntervalMedium, "agent status timeout") }) - t.Run("Dormant", func(t *testing.T) { // this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed t.Parallel() @@ -1640,7 +1653,6 @@ func TestWorkspaceFilterManual(t *testing.T) { require.Equal(t, dormantWorkspace.ID, res.Workspaces[0].ID) require.NotNil(t, res.Workspaces[0].DormantAt) }) - t.Run("LastUsed", func(t *testing.T) { t.Parallel() @@ -1747,6 +1759,172 @@ func TestWorkspaceFilterManual(t *testing.T) { require.Len(t, res.Workspaces, 1) require.Equal(t, workspace.ID, res.Workspaces[0].ID) }) + t.Run("Params", func(t *testing.T) { + t.Parallel() + + const ( + paramOneName = "one" + paramTwoName = "two" + paramThreeName = "three" + paramOptional = "optional" + ) + + makeParameters := func(extra ...*proto.RichParameter) *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Parameters: append([]*proto.RichParameter{ + {Name: paramOneName, Description: "", Mutable: true, Type: "string"}, + {Name: paramTwoName, DisplayName: "", Description: "", Mutable: true, Type: "string"}, + {Name: paramThreeName, Description: "", Mutable: true, Type: "string"}, + }, extra...), + }, + }, + }, + }, + ProvisionApply: echo.ApplyComplete, + } + } + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, makeParameters(&proto.RichParameter{Name: paramOptional, Description: "", Mutable: true, Type: "string"})) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + noOptionalVersion := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, makeParameters(), func(request *codersdk.CreateTemplateVersionRequest) { + request.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, noOptionalVersion.ID) + + // foo :: one=foo, two=bar, one=baz, optional=optional + foo := coderdtest.CreateWorkspace(t, client, user.OrganizationID, uuid.Nil, func(request *codersdk.CreateWorkspaceRequest) { + request.TemplateVersionID = version.ID + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: paramOneName, + Value: "foo", + }, + { + Name: paramTwoName, + Value: "bar", + }, + { + Name: paramThreeName, + Value: "baz", + }, + { + Name: paramOptional, + Value: "optional", + }, + } + }) + + // bar :: one=foo, two=bar, three=baz, optional=optional + bar := coderdtest.CreateWorkspace(t, client, user.OrganizationID, uuid.Nil, func(request *codersdk.CreateWorkspaceRequest) { + request.TemplateVersionID = version.ID + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: paramOneName, + Value: "bar", + }, + { + Name: paramTwoName, + Value: "bar", + }, + { + Name: paramThreeName, + Value: "baz", + }, + { + Name: paramOptional, + Value: "optional", + }, + } + }) + + // baz :: one=baz, two=baz, three=baz + baz := coderdtest.CreateWorkspace(t, client, user.OrganizationID, uuid.Nil, func(request *codersdk.CreateWorkspaceRequest) { + request.TemplateVersionID = noOptionalVersion.ID + request.RichParameterValues = []codersdk.WorkspaceBuildParameter{ + { + Name: paramOneName, + Value: "unique", + }, + { + Name: paramTwoName, + Value: "baz", + }, + { + Name: paramThreeName, + Value: "baz", + }, + } + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + //nolint:tparallel,paralleltest + t.Run("has_param", func(t *testing.T) { + // Checks the existence of a param value + // all match + all, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("param:%s", paramOneName), + }) + require.NoError(t, err) + expectIDs(t, []codersdk.Workspace{foo, bar, baz}, all.Workspaces) + + // Some match + optional, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("param:%s", paramOptional), + }) + require.NoError(t, err) + expectIDs(t, []codersdk.Workspace{foo, bar}, optional.Workspaces) + + // None match + none, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "param:not-a-param", + }) + require.NoError(t, err) + require.Len(t, none.Workspaces, 0) + }) + + //nolint:tparallel,paralleltest + t.Run("exact_param", func(t *testing.T) { + // All match + all, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("param:%s=%s", paramThreeName, "baz"), + }) + require.NoError(t, err) + expectIDs(t, []codersdk.Workspace{foo, bar, baz}, all.Workspaces) + + // Two match + two, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("param:%s=%s", paramTwoName, "bar"), + }) + require.NoError(t, err) + expectIDs(t, []codersdk.Workspace{foo, bar}, two.Workspaces) + + // Only 1 matches + one, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("param:%s=%s", paramOneName, "foo"), + }) + require.NoError(t, err) + expectIDs(t, []codersdk.Workspace{foo}, one.Workspaces) + }) + + //nolint:tparallel,paralleltest + t.Run("exact_param_and_has", func(t *testing.T) { + all, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: fmt.Sprintf("param:not=athing param:%s=%s param:%s=%s", paramOptional, "optional", paramOneName, "unique"), + }) + require.NoError(t, err) + expectIDs(t, []codersdk.Workspace{foo, bar, baz}, all.Workspaces) + }) + }) } func TestOffsetLimit(t *testing.T) {