diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 0800fb5dd0..e3572a2e10 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -7534,6 +7534,23 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database. } } + if arg.UsingActive.Valid { + build, err := q.getLatestWorkspaceBuildByWorkspaceIDNoLock(ctx, workspace.ID) + if err != nil { + return nil, xerrors.Errorf("get latest build: %w", err) + } + + template, err := q.getTemplateByIDNoLock(ctx, workspace.TemplateID) + if err != nil { + return nil, xerrors.Errorf("get template: %w", err) + } + + updated := build.TemplateVersionID == template.ActiveVersionID + if arg.UsingActive.Bool != updated { + continue + } + } + if !arg.Deleted && workspace.Deleted { continue } diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 7443f1231a..7586be1834 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -198,7 +198,7 @@ type workspaceQuerier interface { // This code is copied from `GetWorkspaces` and adds the authorized filter WHERE // clause. func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) { - authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWithoutACL()) + authorizedFilter, err := prepared.CompileToSQL(ctx, rbac.ConfigWorkspaces()) if err != nil { return nil, xerrors.Errorf("compile authorized filter: %w", err) } @@ -225,6 +225,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa arg.Dormant, arg.LastUsedBefore, arg.LastUsedAfter, + arg.UsingActive, arg.Offset, arg.Limit, ) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 4c4bfc6012..b18662df35 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -11167,7 +11167,7 @@ func (q *sqlQuerier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Conte const getWorkspaces = `-- name: GetWorkspaces :many 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, - COALESCE(template_name.template_name, 'unknown') as template_name, + COALESCE(template.name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, COUNT(*) OVER () as count @@ -11208,12 +11208,12 @@ LEFT JOIN LATERAL ( ) latest_build ON TRUE LEFT JOIN LATERAL ( SELECT - templates.name AS template_name + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, use_max_ttl FROM templates WHERE templates.id = workspaces.template_id -) template_name ON true +) template ON true WHERE -- Optionally include deleted workspaces workspaces.deleted = $1 @@ -11347,6 +11347,11 @@ WHERE workspaces.last_used_at >= $12 ELSE true END + AND CASE + WHEN $13 :: boolean IS NOT NULL THEN + (latest_build.template_version_id = template.active_version_id) = $13 :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY @@ -11358,28 +11363,29 @@ ORDER BY LOWER(workspaces.name) ASC LIMIT CASE - WHEN $14 :: integer > 0 THEN - $14 + WHEN $15 :: integer > 0 THEN + $15 END OFFSET - $13 + $14 ` type GetWorkspacesParams struct { - Deleted bool `db:"deleted" json:"deleted"` - Status string `db:"status" json:"status"` - OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` - 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"` - Name string `db:"name" json:"name"` - HasAgent string `db:"has_agent" json:"has_agent"` - AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` - Dormant bool `db:"dormant" json:"dormant"` - LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` - LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` - Offset int32 `db:"offset_" json:"offset_"` - Limit int32 `db:"limit_" json:"limit_"` + Deleted bool `db:"deleted" json:"deleted"` + Status string `db:"status" json:"status"` + OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` + 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"` + Name string `db:"name" json:"name"` + HasAgent string `db:"has_agent" json:"has_agent"` + AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` + Dormant bool `db:"dormant" json:"dormant"` + LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` + LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` + UsingActive sql.NullBool `db:"using_active" json:"using_active"` + Offset int32 `db:"offset_" json:"offset_"` + Limit int32 `db:"limit_" json:"limit_"` } type GetWorkspacesRow struct { @@ -11417,6 +11423,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) arg.Dormant, arg.LastUsedBefore, arg.LastUsedAfter, + arg.UsingActive, arg.Offset, arg.Limit, ) diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index b400a1165b..c60af4f3ce 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -79,7 +79,7 @@ WHERE -- name: GetWorkspaces :many SELECT workspaces.*, - COALESCE(template_name.template_name, 'unknown') as template_name, + COALESCE(template.name, 'unknown') as template_name, latest_build.template_version_id, latest_build.template_version_name, COUNT(*) OVER () as count @@ -120,12 +120,12 @@ LEFT JOIN LATERAL ( ) latest_build ON TRUE LEFT JOIN LATERAL ( SELECT - templates.name AS template_name + * FROM templates WHERE templates.id = workspaces.template_id -) template_name ON true +) template ON true WHERE -- Optionally include deleted workspaces workspaces.deleted = @deleted @@ -259,6 +259,11 @@ WHERE workspaces.last_used_at >= @last_used_after ELSE true END + AND CASE + WHEN sqlc.narg('using_active') :: boolean IS NOT NULL THEN + (latest_build.template_version_id = template.active_version_id) = sqlc.narg('using_active') :: boolean + ELSE true + END -- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces -- @authorize_filter ORDER BY diff --git a/coderd/rbac/authz.go b/coderd/rbac/authz.go index f97f593d20..807adeb361 100644 --- a/coderd/rbac/authz.go +++ b/coderd/rbac/authz.go @@ -611,6 +611,12 @@ func ConfigWithoutACL() regosql.ConvertConfig { } } +func ConfigWorkspaces() regosql.ConvertConfig { + return regosql.ConvertConfig{ + VariableConverter: regosql.WorkspaceConverter(), + } +} + func Compile(cfg regosql.ConvertConfig, pa *PartialAuthorizer) (AuthorizeFilter, error) { root, err := regosql.ConvertRegoAst(cfg, pa.partialQueries) if err != nil { diff --git a/coderd/rbac/regosql/configs.go b/coderd/rbac/regosql/configs.go index 68d3b6264c..e50d6d5fbe 100644 --- a/coderd/rbac/regosql/configs.go +++ b/coderd/rbac/regosql/configs.go @@ -53,6 +53,20 @@ func UserConverter() *sqltypes.VariableConverter { return matcher } +func WorkspaceConverter() *sqltypes.VariableConverter { + matcher := sqltypes.NewVariableConverter().RegisterMatcher( + resourceIDMatcher(), + sqltypes.StringVarMatcher("workspaces.organization_id :: text", []string{"input", "object", "org_owner"}), + userOwnerMatcher(), + ) + matcher.RegisterMatcher( + sqltypes.AlwaysFalse(groupACLMatcher(matcher)), + sqltypes.AlwaysFalse(userACLMatcher(matcher)), + ) + + return matcher +} + // NoACLConverter should be used when the target SQL table does not contain // group or user ACL columns. func NoACLConverter() *sqltypes.VariableConverter { diff --git a/coderd/searchquery/search.go b/coderd/searchquery/search.go index 405c59403f..ce51d8ac7c 100644 --- a/coderd/searchquery/search.go +++ b/coderd/searchquery/search.go @@ -1,6 +1,7 @@ package searchquery import ( + "database/sql" "fmt" "net/url" "strings" @@ -110,6 +111,14 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT filter.Dormant = parser.Boolean(values, false, "dormant") filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after") filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before") + filter.UsingActive = sql.NullBool{ + // Invert the value of the query parameter to get the correct value. + // UsingActive returns if the workspace is on the latest template active version. + Bool: !parser.Boolean(values, true, "outdated"), + // Only include this search term if it was provided. Otherwise default to omitting it + // which will return all workspaces. + Valid: values.Has("outdated"), + } parser.ErrorExcessParams(values) return filter, parser.Errors diff --git a/coderd/searchquery/search_test.go b/coderd/searchquery/search_test.go index 9a2f1f0ed0..50a3feb226 100644 --- a/coderd/searchquery/search_test.go +++ b/coderd/searchquery/search_test.go @@ -1,6 +1,7 @@ package searchquery_test import ( + "database/sql" "fmt" "strings" "testing" @@ -116,7 +117,26 @@ func TestSearchWorkspace(t *testing.T) { OwnerUsername: "foo", }, }, - + { + Name: "Outdated", + Query: `outdated:true`, + Expected: database.GetWorkspacesParams{ + UsingActive: sql.NullBool{ + Bool: false, + Valid: true, + }, + }, + }, + { + Name: "Updated", + Query: `outdated:false`, + Expected: database.GetWorkspacesParams{ + UsingActive: sql.NullBool{ + Bool: true, + Valid: true, + }, + }, + }, // Failures { Name: "NoPrefix", diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 18566f6b3c..9dfc18a395 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -1632,6 +1632,56 @@ func TestWorkspaceFilterManual(t *testing.T) { require.Len(t, afterRes.Workspaces, 1) require.Equal(t, after.ID, afterRes.Workspaces[0].ID) }) + t.Run("Updated", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + // Workspace is up-to-date + res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "outdated:false", + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 1) + require.Equal(t, workspace.ID, res.Workspaces[0].ID) + + res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "outdated:true", + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 0) + + // Now make it out of date + newTv := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil, func(request *codersdk.CreateTemplateVersionRequest) { + request.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + err = client.UpdateActiveTemplateVersion(ctx, template.ID, codersdk.UpdateActiveTemplateVersion{ + ID: newTv.ID, + }) + require.NoError(t, err) + + // Check the query again + res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "outdated:false", + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 0) + + res, err = client.Workspaces(ctx, codersdk.WorkspaceFilter{ + FilterQuery: "outdated:true", + }) + require.NoError(t, err) + require.Len(t, res.Workspaces, 1) + require.Equal(t, workspace.ID, res.Workspaces[0].ID) + }) } func TestOffsetLimit(t *testing.T) { diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index 112aac6f18..1974d923bd 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -22,6 +22,7 @@ export const workspaceFilterQuery = { running: "status:running", failed: "status:failed", dormant: "dormant:true", + outdated: "outdated:true", }; type FilterPreset = { @@ -48,6 +49,10 @@ const PRESET_FILTERS: FilterPreset[] = [ query: workspaceFilterQuery.failed, name: "Failed workspaces", }, + { + query: workspaceFilterQuery.outdated, + name: "Outdated workspaces", + }, ]; // Defined outside component so that the array doesn't get reconstructed each render