mirror of https://github.com/coder/coder.git
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:
parent
081fbef097
commit
d6ba0dfecb
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
|
|
Loading…
Reference in New Issue