feat: add "updated" search param to workspaces (#11714)

* feat: add "updated" search param to workspaces
* rego -> sql needs to specify which <table>.organization_id
This commit is contained in:
Steven Masley 2024-01-23 11:52:06 -06:00 committed by GitHub
parent 081fbef097
commit d6ba0dfecb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 159 additions and 25 deletions

View File

@ -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 { if !arg.Deleted && workspace.Deleted {
continue continue
} }

View File

@ -198,7 +198,7 @@ type workspaceQuerier interface {
// This code is copied from `GetWorkspaces` and adds the authorized filter WHERE // This code is copied from `GetWorkspaces` and adds the authorized filter WHERE
// clause. // clause.
func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspacesParams, prepared rbac.PreparedAuthorized) ([]GetWorkspacesRow, error) { 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 { if err != nil {
return nil, xerrors.Errorf("compile authorized filter: %w", err) 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.Dormant,
arg.LastUsedBefore, arg.LastUsedBefore,
arg.LastUsedAfter, arg.LastUsedAfter,
arg.UsingActive,
arg.Offset, arg.Offset,
arg.Limit, arg.Limit,
) )

View File

@ -11167,7 +11167,7 @@ func (q *sqlQuerier) GetWorkspaceUniqueOwnerCountByTemplateIDs(ctx context.Conte
const getWorkspaces = `-- name: GetWorkspaces :many const getWorkspaces = `-- name: GetWorkspaces :many
SELECT 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.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_id,
latest_build.template_version_name, latest_build.template_version_name,
COUNT(*) OVER () as count COUNT(*) OVER () as count
@ -11208,12 +11208,12 @@ LEFT JOIN LATERAL (
) latest_build ON TRUE ) latest_build ON TRUE
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT 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 FROM
templates templates
WHERE WHERE
templates.id = workspaces.template_id templates.id = workspaces.template_id
) template_name ON true ) template ON true
WHERE WHERE
-- Optionally include deleted workspaces -- Optionally include deleted workspaces
workspaces.deleted = $1 workspaces.deleted = $1
@ -11347,6 +11347,11 @@ WHERE
workspaces.last_used_at >= $12 workspaces.last_used_at >= $12
ELSE true ELSE true
END 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 clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter -- @authorize_filter
ORDER BY ORDER BY
@ -11358,28 +11363,29 @@ ORDER BY
LOWER(workspaces.name) ASC LOWER(workspaces.name) ASC
LIMIT LIMIT
CASE CASE
WHEN $14 :: integer > 0 THEN WHEN $15 :: integer > 0 THEN
$14 $15
END END
OFFSET OFFSET
$13 $14
` `
type GetWorkspacesParams struct { type GetWorkspacesParams struct {
Deleted bool `db:"deleted" json:"deleted"` Deleted bool `db:"deleted" json:"deleted"`
Status string `db:"status" json:"status"` Status string `db:"status" json:"status"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"` OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
OwnerUsername string `db:"owner_username" json:"owner_username"` OwnerUsername string `db:"owner_username" json:"owner_username"`
TemplateName string `db:"template_name" json:"template_name"` TemplateName string `db:"template_name" json:"template_name"`
TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"` TemplateIDs []uuid.UUID `db:"template_ids" json:"template_ids"`
Name string `db:"name" json:"name"` Name string `db:"name" json:"name"`
HasAgent string `db:"has_agent" json:"has_agent"` HasAgent string `db:"has_agent" json:"has_agent"`
AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"` AgentInactiveDisconnectTimeoutSeconds int64 `db:"agent_inactive_disconnect_timeout_seconds" json:"agent_inactive_disconnect_timeout_seconds"`
Dormant bool `db:"dormant" json:"dormant"` Dormant bool `db:"dormant" json:"dormant"`
LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"` LastUsedBefore time.Time `db:"last_used_before" json:"last_used_before"`
LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"` LastUsedAfter time.Time `db:"last_used_after" json:"last_used_after"`
Offset int32 `db:"offset_" json:"offset_"` UsingActive sql.NullBool `db:"using_active" json:"using_active"`
Limit int32 `db:"limit_" json:"limit_"` Offset int32 `db:"offset_" json:"offset_"`
Limit int32 `db:"limit_" json:"limit_"`
} }
type GetWorkspacesRow struct { type GetWorkspacesRow struct {
@ -11417,6 +11423,7 @@ func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams)
arg.Dormant, arg.Dormant,
arg.LastUsedBefore, arg.LastUsedBefore,
arg.LastUsedAfter, arg.LastUsedAfter,
arg.UsingActive,
arg.Offset, arg.Offset,
arg.Limit, arg.Limit,
) )

View File

@ -79,7 +79,7 @@ WHERE
-- name: GetWorkspaces :many -- name: GetWorkspaces :many
SELECT SELECT
workspaces.*, 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_id,
latest_build.template_version_name, latest_build.template_version_name,
COUNT(*) OVER () as count COUNT(*) OVER () as count
@ -120,12 +120,12 @@ LEFT JOIN LATERAL (
) latest_build ON TRUE ) latest_build ON TRUE
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT SELECT
templates.name AS template_name *
FROM FROM
templates templates
WHERE WHERE
templates.id = workspaces.template_id templates.id = workspaces.template_id
) template_name ON true ) template ON true
WHERE WHERE
-- Optionally include deleted workspaces -- Optionally include deleted workspaces
workspaces.deleted = @deleted workspaces.deleted = @deleted
@ -259,6 +259,11 @@ WHERE
workspaces.last_used_at >= @last_used_after workspaces.last_used_at >= @last_used_after
ELSE true ELSE true
END 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 clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter -- @authorize_filter
ORDER BY ORDER BY

View File

@ -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) { func Compile(cfg regosql.ConvertConfig, pa *PartialAuthorizer) (AuthorizeFilter, error) {
root, err := regosql.ConvertRegoAst(cfg, pa.partialQueries) root, err := regosql.ConvertRegoAst(cfg, pa.partialQueries)
if err != nil { if err != nil {

View File

@ -53,6 +53,20 @@ func UserConverter() *sqltypes.VariableConverter {
return matcher 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 // NoACLConverter should be used when the target SQL table does not contain
// group or user ACL columns. // group or user ACL columns.
func NoACLConverter() *sqltypes.VariableConverter { func NoACLConverter() *sqltypes.VariableConverter {

View File

@ -1,6 +1,7 @@
package searchquery package searchquery
import ( import (
"database/sql"
"fmt" "fmt"
"net/url" "net/url"
"strings" "strings"
@ -110,6 +111,14 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
filter.Dormant = parser.Boolean(values, false, "dormant") filter.Dormant = parser.Boolean(values, false, "dormant")
filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after") filter.LastUsedAfter = parser.Time3339Nano(values, time.Time{}, "last_used_after")
filter.LastUsedBefore = parser.Time3339Nano(values, time.Time{}, "last_used_before") 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) parser.ErrorExcessParams(values)
return filter, parser.Errors return filter, parser.Errors

View File

@ -1,6 +1,7 @@
package searchquery_test package searchquery_test
import ( import (
"database/sql"
"fmt" "fmt"
"strings" "strings"
"testing" "testing"
@ -116,7 +117,26 @@ func TestSearchWorkspace(t *testing.T) {
OwnerUsername: "foo", 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 // Failures
{ {
Name: "NoPrefix", Name: "NoPrefix",

View File

@ -1632,6 +1632,56 @@ func TestWorkspaceFilterManual(t *testing.T) {
require.Len(t, afterRes.Workspaces, 1) require.Len(t, afterRes.Workspaces, 1)
require.Equal(t, after.ID, afterRes.Workspaces[0].ID) 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) { func TestOffsetLimit(t *testing.T) {

View File

@ -22,6 +22,7 @@ export const workspaceFilterQuery = {
running: "status:running", running: "status:running",
failed: "status:failed", failed: "status:failed",
dormant: "dormant:true", dormant: "dormant:true",
outdated: "outdated:true",
}; };
type FilterPreset = { type FilterPreset = {
@ -48,6 +49,10 @@ const PRESET_FILTERS: FilterPreset[] = [
query: workspaceFilterQuery.failed, query: workspaceFilterQuery.failed,
name: "Failed workspaces", name: "Failed workspaces",
}, },
{
query: workspaceFilterQuery.outdated,
name: "Outdated workspaces",
},
]; ];
// Defined outside component so that the array doesn't get reconstructed each render // Defined outside component so that the array doesn't get reconstructed each render