Skip to content

feat: add impending deletion filter to workspaces page #7860

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 18 commits into from
Jun 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions coderd/apidoc/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions coderd/apidoc/swagger.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

20 changes: 16 additions & 4 deletions coderd/searchquery/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/httpapi"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
)

Expand Down Expand Up @@ -66,16 +67,22 @@ func Users(query string) (database.GetUsersParams, []codersdk.ValidationError) {
return filter, parser.Errors
}

func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) {
type PostFilter struct {
DeletingBy *time.Time `json:"deleting_by" format:"date-time"`
}

func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, PostFilter, []codersdk.ValidationError) {
filter := database.GetWorkspacesParams{
AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),

Offset: int32(page.Offset),
Limit: int32(page.Limit),
}

var postFilter PostFilter

if query == "" {
return filter, nil
return filter, postFilter, nil
}

// Always lowercase for all searches.
Expand All @@ -95,7 +102,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
return nil
})
if len(errors) > 0 {
return filter, errors
return filter, postFilter, errors
}

parser := httpapi.NewQueryParamParser()
Expand All @@ -104,8 +111,13 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
filter.Name = parser.String(values, "", "name")
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
filter.HasAgent = parser.String(values, "", "has-agent")

if _, ok := values["deleting_by"]; ok {
postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02"))
}

parser.ErrorExcessParams(values)
return filter, parser.Errors
return filter, postFilter, parser.Errors
}

func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {
Expand Down
56 changes: 50 additions & 6 deletions coderd/searchquery/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import (
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/rbac"
"github.com/coder/coder/coderd/searchquery"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
)

Expand Down Expand Up @@ -148,17 +150,18 @@ func TestSearchWorkspace(t *testing.T) {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
values, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
values, postFilter, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
if c.ExpectedErrorContains != "" {
require.True(t, len(errs) > 0, "expect some errors")
assert.True(t, len(errs) > 0, "expect some errors")
var s strings.Builder
for _, err := range errs {
_, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail))
}
require.Contains(t, s.String(), c.ExpectedErrorContains)
assert.Contains(t, s.String(), c.ExpectedErrorContains)
} else {
require.Len(t, errs, 0, "expected no error")
require.Equal(t, c.Expected, values, "expected values")
assert.Empty(t, postFilter)
assert.Len(t, errs, 0, "expected no error")
assert.Equal(t, c.Expected, values, "expected values")
}
})
}
Expand All @@ -167,10 +170,51 @@ func TestSearchWorkspace(t *testing.T) {

query := ``
timeout := 1337 * time.Second
values, errs := searchquery.Workspaces(query, codersdk.Pagination{}, timeout)
values, _, errs := searchquery.Workspaces(query, codersdk.Pagination{}, timeout)
require.Empty(t, errs)
require.Equal(t, int64(timeout.Seconds()), values.AgentInactiveDisconnectTimeoutSeconds)
})

t.Run("TestSearchWorkspacePostFilter", func(t *testing.T) {
t.Parallel()
testCases := []struct {
Name string
Query string
Expected searchquery.PostFilter
}{
{
Name: "Empty",
Query: "",
Expected: searchquery.PostFilter{},
},
{
Name: "DeletingBy",
Query: "deleting_by:2023-06-09",
Expected: searchquery.PostFilter{
DeletingBy: ptr.Ref(time.Date(
2023, 6, 9, 0, 0, 0, 0, time.UTC)),
},
},
{
Name: "MultipleParams",
Query: "deleting_by:2023-06-09 name:workspace-name",
Expected: searchquery.PostFilter{
DeletingBy: ptr.Ref(time.Date(
2023, 6, 9, 0, 0, 0, 0, time.UTC)),
},
},
}

for _, c := range testCases {
c := c
t.Run(c.Name, func(t *testing.T) {
t.Parallel()
_, postFilter, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
assert.Len(t, errs, 0, "expected no error")
assert.Equal(t, c.Expected, postFilter, "expected values")
})
}
})
}

func TestSearchAudit(t *testing.T) {
Expand Down
23 changes: 21 additions & 2 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
// @Param name query string false "Filter with partial-match by workspace name"
// @Param status query string false "Filter by workspace status" Enums(pending,running,stopping,stopped,failed,canceling,canceled,deleted,deleting)
// @Param has_agent query string false "Filter by agent status" Enums(connected,connecting,disconnected,timeout)
// @Param deleting_by query string false "Filter workspaces scheduled to be deleted by this time"
// @Success 200 {object} codersdk.WorkspacesResponse
// @Router /workspaces [get]
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
Expand All @@ -118,7 +119,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
}

queryStr := r.URL.Query().Get("q")
filter, errs := searchquery.Workspaces(queryStr, page, api.AgentInactiveDisconnectTimeout)
filter, postFilter, errs := searchquery.Workspaces(queryStr, page, api.AgentInactiveDisconnectTimeout)
if len(errs) > 0 {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Invalid workspace search query.",
Expand Down Expand Up @@ -178,8 +179,26 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
return
}

var filteredWorkspaces []codersdk.Workspace
// apply post filters, if they exist
if postFilter.DeletingBy == nil {
filteredWorkspaces = append(filteredWorkspaces, wss...)
} else {
for _, v := range wss {
if v.DeletingAt == nil {
continue
}
// get the beginning of the day on which deletion is scheduled
truncatedDeletionAt := time.Date(v.DeletingAt.Year(), v.DeletingAt.Month(), v.DeletingAt.Day(), 0, 0, 0, 0, v.DeletingAt.Location())
if truncatedDeletionAt.After(*postFilter.DeletingBy) {
continue
}
filteredWorkspaces = append(filteredWorkspaces, v)
}
}

httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
Workspaces: wss,
Workspaces: filteredWorkspaces,
Count: int(workspaceRows[0].Count),
})
}
Expand Down
57 changes: 57 additions & 0 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"net/http"
"os"
"strings"
"sync/atomic"
"testing"
"time"

Expand Down Expand Up @@ -1209,6 +1210,62 @@ func TestWorkspaceFilterManual(t *testing.T) {
return workspaces.Count == 1
}, testutil.IntervalMedium, "agent status timeout")
})

t.Run("FilterQueryHasDeletingByAndUnlicensed", func(t *testing.T) {
// this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed
t.Parallel()
inactivityTTL := 1 * 24 * time.Hour
var setCalled int64

client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
if atomic.AddInt64(&setCalled, 1) == 2 {
assert.Equal(t, inactivityTTL, options.InactivityTTL)
}
template.InactivityTTL = int64(options.InactivityTTL)
return template, nil
},
},
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
Parse: echo.ParseComplete,
ProvisionPlan: echo.ProvisionComplete,
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)

// update template with inactivity ttl
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
InactivityTTLMillis: inactivityTTL.Milliseconds(),
})

assert.NoError(t, err)
assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis)

workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)

// stop build so workspace is inactive
stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID)

res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(inactivityTTL).Format("2006-01-02")),
})

assert.NoError(t, err)
// we are expecting that no workspaces are returned as user is unlicensed
// and template.InactivityTTL should be 0
assert.Len(t, res.Workspaces, 0)
})
}

func TestOffsetLimit(t *testing.T) {
Expand Down
15 changes: 8 additions & 7 deletions docs/api/workspaces.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,13 +383,14 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \

### Parameters

| Name | In | Type | Required | Description |
| ----------- | ----- | ------ | -------- | ------------------------------------------- |
| `owner` | query | string | false | Filter by owner username |
| `template` | query | string | false | Filter by template name |
| `name` | query | string | false | Filter with partial-match by workspace name |
| `status` | query | string | false | Filter by workspace status |
| `has_agent` | query | string | false | Filter by agent status |
| Name | In | Type | Required | Description |
| ------------- | ----- | ------ | -------- | ------------------------------------------------------ |
| `owner` | query | string | false | Filter by owner username |
| `template` | query | string | false | Filter by template name |
| `name` | query | string | false | Filter with partial-match by workspace name |
| `status` | query | string | false | Filter by workspace status |
| `has_agent` | query | string | false | Filter by agent status |
| `deleting_by` | query | string | false | Filter workspaces scheduled to be deleted by this time |

#### Enumerated Values

Expand Down
56 changes: 56 additions & 0 deletions enterprise/coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package coderd_test

import (
"context"
"fmt"
"net/http"
"testing"
"time"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/coderd/util/ptr"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/enterprise/coderd/coderdenttest"
Expand Down Expand Up @@ -70,3 +73,56 @@ func TestCreateWorkspace(t *testing.T) {
require.Error(t, err)
})
}

func TestWorkspacesFiltering(t *testing.T) {
t.Parallel()

t.Run("FilterQueryHasDeletingByAndLicensed", func(t *testing.T) {
t.Parallel()

inactivityTTL := 1 * 24 * time.Hour

client := coderdenttest.New(t, &coderdenttest.Options{
Options: &coderdtest.Options{
IncludeProvisionerDaemon: true,
},
})
user := coderdtest.CreateFirstUser(t, client)
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
Features: license.Features{
codersdk.FeatureAdvancedTemplateScheduling: 1,
},
})

version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)

coderdtest.AwaitTemplateVersionJob(t, client, version.ID)

// update template with inactivity ttl
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
defer cancel()

template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
InactivityTTLMillis: inactivityTTL.Milliseconds(),
})

assert.NoError(t, err)
assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis)

workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)

// stop build so workspace is inactive
stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID)

res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
// adding a second to time.Now() to give some buffer in case test runs quickly
FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(time.Second).Add(inactivityTTL).Format("2006-01-02")),
})
assert.NoError(t, err)
assert.Len(t, res.Workspaces, 1)
assert.Equal(t, workspace.ID, res.Workspaces[0].ID)
})
}
Loading