feat: filter for running workspaces (#4157)

* Refactor workspaces xservice

* Remove layout comment

* Format

* Add comments

* Add running workspaces filter to frontend

* Start on backend - add status to filter

* Update sql and add test - wip

* Attempt to unconvert status for easier querying

* Fix syntax

* Join jobs table, untested

* sql

* Add Status to GetAuthorizedWorkspaces

* Update job tests to have canceled time

* fmt

* add status filter to database fake

Co-authored-by: Colin Adler <colin1adler@gmail.com>
This commit is contained in:
Presley Pizzo
2022-10-11 13:50:41 -04:00
committed by GitHub
parent aefb477e21
commit 62357084ba
15 changed files with 434 additions and 99 deletions

View File

@ -553,7 +553,8 @@ func (q *fakeQuerier) GetWorkspaces(ctx context.Context, arg database.GetWorkspa
return workspaces, err
}
func (q *fakeQuerier) GetAuthorizedWorkspaces(_ context.Context, arg database.GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]database.Workspace, error) {
//nolint:gocyclo
func (q *fakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.GetWorkspacesParams, authorizedFilter rbac.AuthorizeFilter) ([]database.Workspace, error) {
q.mutex.RLock()
defer q.mutex.RUnlock()
@ -562,18 +563,21 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(_ context.Context, arg database.Ge
if arg.OwnerID != uuid.Nil && workspace.OwnerID != arg.OwnerID {
continue
}
if arg.OwnerUsername != "" {
owner, err := q.GetUserByID(context.Background(), workspace.OwnerID)
owner, err := q.GetUserByID(ctx, workspace.OwnerID)
if err == nil && !strings.EqualFold(arg.OwnerUsername, owner.Username) {
continue
}
}
if arg.TemplateName != "" {
template, err := q.GetTemplateByID(context.Background(), workspace.TemplateID)
template, err := q.GetTemplateByID(ctx, workspace.TemplateID)
if err == nil && !strings.EqualFold(arg.TemplateName, template.Name) {
continue
}
}
if !arg.Deleted && workspace.Deleted {
continue
}
@ -581,6 +585,96 @@ func (q *fakeQuerier) GetAuthorizedWorkspaces(_ context.Context, arg database.Ge
if arg.Name != "" && !strings.Contains(strings.ToLower(workspace.Name), strings.ToLower(arg.Name)) {
continue
}
if arg.Status != "" {
build, err := q.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if err != nil {
return nil, xerrors.Errorf("get latest build: %w", err)
}
job, err := q.GetProvisionerJobByID(ctx, build.JobID)
if err != nil {
return nil, xerrors.Errorf("get provisioner job: %w", err)
}
switch arg.Status {
case "pending":
if !job.StartedAt.Valid {
continue
}
case "starting":
if !job.StartedAt.Valid &&
!job.CanceledAt.Valid &&
job.CompletedAt.Valid &&
time.Since(job.UpdatedAt) > 30*time.Second ||
build.Transition != database.WorkspaceTransitionStart {
continue
}
case "running":
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid ||
build.Transition != database.WorkspaceTransitionStart {
continue
}
case "stopping":
if !job.StartedAt.Valid &&
!job.CanceledAt.Valid &&
job.CompletedAt.Valid &&
time.Since(job.UpdatedAt) > 30*time.Second ||
build.Transition != database.WorkspaceTransitionStop {
continue
}
case "stopped":
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid ||
build.Transition != database.WorkspaceTransitionStop {
continue
}
case "failed":
if (!job.CanceledAt.Valid && !job.Error.Valid) ||
(!job.CompletedAt.Valid && !job.Error.Valid) {
continue
}
case "canceling":
if !job.CanceledAt.Valid && job.CompletedAt.Valid {
continue
}
case "canceled":
if !job.CanceledAt.Valid && !job.CompletedAt.Valid {
continue
}
case "deleted":
if !job.StartedAt.Valid &&
job.CanceledAt.Valid &&
!job.CompletedAt.Valid &&
time.Since(job.UpdatedAt) > 30*time.Second ||
build.Transition != database.WorkspaceTransitionDelete {
continue
}
case "deleting":
if !job.CompletedAt.Valid &&
job.CanceledAt.Valid &&
job.Error.Valid &&
build.Transition != database.WorkspaceTransitionDelete {
continue
}
default:
return nil, xerrors.Errorf("unknown workspace status in filter: %q", arg.Status)
}
}
if len(arg.TemplateIds) > 0 {
match := false
for _, id := range arg.TemplateIds {
@ -771,7 +865,7 @@ func (q *fakeQuerier) GetLatestWorkspaceBuildByWorkspaceID(_ context.Context, wo
var row database.WorkspaceBuild
var buildNum int32 = -1
for _, workspaceBuild := range q.workspaceBuilds {
if workspaceBuild.WorkspaceID.String() == workspaceID.String() && workspaceBuild.BuildNumber > buildNum {
if workspaceBuild.WorkspaceID == workspaceID && workspaceBuild.BuildNumber > buildNum {
row = workspaceBuild
buildNum = workspaceBuild.BuildNumber
}
@ -816,7 +910,7 @@ func (q *fakeQuerier) GetLatestWorkspaceBuildsByWorkspaceIDs(_ context.Context,
buildNumbers := make(map[uuid.UUID]int32)
for _, workspaceBuild := range q.workspaceBuilds {
for _, id := range ids {
if id.String() == workspaceBuild.WorkspaceID.String() && workspaceBuild.BuildNumber > buildNumbers[id] {
if id == workspaceBuild.WorkspaceID && workspaceBuild.BuildNumber > buildNumbers[id] {
builds[id] = workspaceBuild
buildNumbers[id] = workspaceBuild.BuildNumber
}

View File

@ -168,6 +168,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
query := fmt.Sprintf("-- name: GetAuthorizedWorkspaces :many\n%s AND %s", getWorkspaces, authorizedFilter.SQLString(rbac.NoACLConfig()))
rows, err := q.db.QueryContext(ctx, query,
arg.Deleted,
arg.Status,
arg.OwnerID,
arg.OwnerUsername,
arg.TemplateName,

View File

@ -5431,48 +5431,133 @@ func (q *sqlQuerier) GetWorkspaceOwnerCountsByTemplateIDs(ctx context.Context, i
const getWorkspaces = `-- name: GetWorkspaces :many
SELECT
id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at
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
FROM
workspaces
workspaces
LEFT JOIN LATERAL (
SELECT
workspace_builds.transition,
provisioner_jobs.started_at,
provisioner_jobs.updated_at,
provisioner_jobs.canceled_at,
provisioner_jobs.completed_at,
provisioner_jobs.error
FROM
workspace_builds
LEFT JOIN
provisioner_jobs
ON
provisioner_jobs.id = workspace_builds.job_id
WHERE
workspace_builds.workspace_id = workspaces.id
ORDER BY
build_number DESC
LIMIT
1
) latest_build ON TRUE
WHERE
-- Optionally include deleted workspaces
-- Optionally include deleted workspaces
workspaces.deleted = $1
-- Filter by owner_id
AND CASE
WHEN $2 :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = $2
WHEN $2 :: text != '' THEN
CASE
WHEN $2 = 'pending' THEN
latest_build.started_at IS NULL
WHEN $2 = 'starting' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'start'::workspace_transition
WHEN $2 = 'running' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'start'::workspace_transition
WHEN $2 = 'stopping' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'stop'::workspace_transition
WHEN $2 = 'stopped' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'stop'::workspace_transition
WHEN $2 = 'failed' THEN
(latest_build.canceled_at IS NOT NULL AND
latest_build.error IS NOT NULL) OR
(latest_build.completed_at IS NOT NULL AND
latest_build.error IS NOT NULL)
WHEN $2 = 'canceling' THEN
latest_build.canceled_at IS NOT NULL AND
latest_build.completed_at IS NULL
WHEN $2 = 'canceled' THEN
latest_build.canceled_at IS NOT NULL AND
latest_build.completed_at IS NOT NULL
WHEN $2 = 'deleted' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NOT NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'delete'::workspace_transition
WHEN $2 = 'deleting' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'delete'::workspace_transition
ELSE
true
END
ELSE true
END
-- Filter by owner_name
-- Filter by owner_id
AND CASE
WHEN $3 :: text != '' THEN
owner_id = (SELECT id FROM users WHERE lower(username) = lower($3))
WHEN $3 :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = $3
ELSE true
END
-- Filter by owner_name
AND CASE
WHEN $4 :: text != '' THEN
owner_id = (SELECT id FROM users WHERE lower(username) = lower($4))
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.
-- Use the organization filter to restrict to 1 org if needed.
AND CASE
WHEN $4 :: text != '' THEN
template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($4))
WHEN $5 :: text != '' THEN
template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower($5))
ELSE true
END
-- Filter by template_ids
AND CASE
WHEN array_length($5 :: uuid[], 1) > 0 THEN
template_id = ANY($5)
WHEN array_length($6 :: uuid[], 1) > 0 THEN
template_id = ANY($6)
ELSE true
END
-- Filter by name, matching on substring
AND CASE
WHEN $6 :: text != '' THEN
name ILIKE '%' || $6 || '%'
WHEN $7 :: text != '' THEN
name ILIKE '%' || $7 || '%'
ELSE true
END
`
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"`
@ -5483,6 +5568,7 @@ type GetWorkspacesParams struct {
func (q *sqlQuerier) GetWorkspaces(ctx context.Context, arg GetWorkspacesParams) ([]Workspace, error) {
rows, err := q.db.QueryContext(ctx, getWorkspaces,
arg.Deleted,
arg.Status,
arg.OwnerID,
arg.OwnerUsername,
arg.TemplateName,

View File

@ -10,19 +10,103 @@ LIMIT
-- name: GetWorkspaces :many
SELECT
*
workspaces.*
FROM
workspaces
workspaces
LEFT JOIN LATERAL (
SELECT
workspace_builds.transition,
provisioner_jobs.started_at,
provisioner_jobs.updated_at,
provisioner_jobs.canceled_at,
provisioner_jobs.completed_at,
provisioner_jobs.error
FROM
workspace_builds
LEFT JOIN
provisioner_jobs
ON
provisioner_jobs.id = workspace_builds.job_id
WHERE
workspace_builds.workspace_id = workspaces.id
ORDER BY
build_number DESC
LIMIT
1
) latest_build ON TRUE
WHERE
-- Optionally include deleted workspaces
-- Optionally include deleted workspaces
workspaces.deleted = @deleted
AND CASE
WHEN @status :: text != '' THEN
CASE
WHEN @status = 'pending' THEN
latest_build.started_at IS NULL
WHEN @status = 'starting' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'start'::workspace_transition
WHEN @status = 'running' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'start'::workspace_transition
WHEN @status = 'stopping' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'stop'::workspace_transition
WHEN @status = 'stopped' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'stop'::workspace_transition
WHEN @status = 'failed' THEN
(latest_build.canceled_at IS NOT NULL AND
latest_build.error IS NOT NULL) OR
(latest_build.completed_at IS NOT NULL AND
latest_build.error IS NOT NULL)
WHEN @status = 'canceling' THEN
latest_build.canceled_at IS NOT NULL AND
latest_build.completed_at IS NULL
WHEN @status = 'canceled' THEN
latest_build.canceled_at IS NOT NULL AND
latest_build.completed_at IS NOT NULL
WHEN @status = 'deleted' THEN
latest_build.started_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.completed_at IS NOT NULL AND
latest_build.updated_at - INTERVAL '30 seconds' < NOW() AND
latest_build.transition = 'delete'::workspace_transition
WHEN @status = 'deleting' THEN
latest_build.completed_at IS NOT NULL AND
latest_build.canceled_at IS NULL AND
latest_build.error IS NULL AND
latest_build.transition = 'delete'::workspace_transition
ELSE
true
END
ELSE true
END
-- Filter by owner_id
AND CASE
WHEN @owner_id :: uuid != '00000000-00000000-00000000-00000000' THEN
owner_id = @owner_id
ELSE true
END
-- Filter by owner_name
-- Filter by owner_name
AND CASE
WHEN @owner_username :: text != '' THEN
owner_id = (SELECT id FROM users WHERE lower(username) = lower(@owner_username))
@ -30,7 +114,7 @@ WHERE
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.
-- Use the organization filter to restrict to 1 org if needed.
AND CASE
WHEN @template_name :: text != '' THEN
template_id = ANY(SELECT id FROM templates WHERE lower(name) = lower(@template_name))
@ -45,7 +129,7 @@ WHERE
-- Filter by name, matching on substring
AND CASE
WHEN @name :: text != '' THEN
name ILIKE '%' || @name || '%'
name ILIKE '%' || @name || '%'
ELSE true
END
;

View File

@ -328,6 +328,9 @@ func convertProvisionerJob(provisionerJob database.ProvisionerJob) codersdk.Prov
if provisionerJob.CompletedAt.Valid {
job.CompletedAt = &provisionerJob.CompletedAt.Time
}
if provisionerJob.CanceledAt.Valid {
job.CanceledAt = &provisionerJob.CanceledAt.Time
}
if provisionerJob.WorkerID.Valid {
job.WorkerID = &provisionerJob.WorkerID.UUID
}

View File

@ -185,7 +185,8 @@ func TestConvertProvisionerJob_Unit(t *testing.T) {
CompletedAt: invalidNullTimeMock,
},
expected: codersdk.ProvisionerJob{
Status: codersdk.ProvisionerJobCanceling,
CanceledAt: &validNullTimeMock.Time,
Status: codersdk.ProvisionerJobCanceling,
},
},
{
@ -196,6 +197,7 @@ func TestConvertProvisionerJob_Unit(t *testing.T) {
Error: errorMock,
},
expected: codersdk.ProvisionerJob{
CanceledAt: &validNullTimeMock.Time,
CompletedAt: &validNullTimeMock.Time,
Status: codersdk.ProvisionerJobFailed,
Error: errorMock.String,
@ -208,6 +210,7 @@ func TestConvertProvisionerJob_Unit(t *testing.T) {
CompletedAt: validNullTimeMock,
},
expected: codersdk.ProvisionerJob{
CanceledAt: &validNullTimeMock.Time,
CompletedAt: &validNullTimeMock.Time,
Status: codersdk.ProvisionerJobCanceled,
},

View File

@ -1117,6 +1117,7 @@ func workspaceSearchQuery(query string) (database.GetWorkspacesParams, []codersd
OwnerUsername: parser.String(searchParams, "", "owner"),
TemplateName: parser.String(searchParams, "", "template"),
Name: parser.String(searchParams, "", "name"),
Status: parser.String(searchParams, "", "status"),
}
return filter, parser.Errors

View File

@ -711,6 +711,52 @@ func TestWorkspaceFilterManual(t *testing.T) {
require.Len(t, ws, 1)
require.Equal(t, workspace.ID, ws[0].ID)
})
t.Run("Status", 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.AwaitTemplateVersionJob(t, client, version.ID)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
workspace1 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
workspace2 := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
// wait for workspaces to be "running"
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace1.LatestBuild.ID)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, workspace2.LatestBuild.ID)
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()
// filter finds both running workspaces
ws1, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{})
require.NoError(t, err)
require.Len(t, ws1, 2)
// stop workspace1
build1 := coderdtest.CreateWorkspaceBuild(t, client, workspace1, database.WorkspaceTransitionStop)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, build1.ID)
// filter finds one running workspace
ws2, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Status: "running",
})
require.NoError(t, err)
require.Len(t, ws2, 1)
require.Equal(t, workspace2.ID, ws2[0].ID)
// stop workspace2
build2 := coderdtest.CreateWorkspaceBuild(t, client, workspace2, database.WorkspaceTransitionStop)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, build2.ID)
// filter finds no running workspaces
ws3, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
Status: "running",
})
require.NoError(t, err)
require.Len(t, ws3, 0)
})
t.Run("FilterQuery", func(t *testing.T) {
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true})