Skip to content

Commit 940f86e

Browse files
Kira-Pilotpull[bot]
authored andcommitted
feat: add impending deletion filter to workspaces page (#7860)
* add workspace deletion dialog * add deleting_by query param * added test * filtering on workspaces to be deleted * cleaned up form * added story * added banner filter * PR feedback * fix lint and stories * PR feedback * added enterprise test * added unit tests in search_test.go * remove unused fn * unstaged changes
1 parent 8f527ae commit 940f86e

19 files changed

+500
-180
lines changed

coderd/apidoc/docs.go

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 6 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/searchquery/search.go

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

1313
"github.com/coder/coder/coderd/database"
1414
"github.com/coder/coder/coderd/httpapi"
15+
"github.com/coder/coder/coderd/util/ptr"
1516
"github.com/coder/coder/codersdk"
1617
)
1718

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

69-
func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, []codersdk.ValidationError) {
70+
type PostFilter struct {
71+
DeletingBy *time.Time `json:"deleting_by" format:"date-time"`
72+
}
73+
74+
func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectTimeout time.Duration) (database.GetWorkspacesParams, PostFilter, []codersdk.ValidationError) {
7075
filter := database.GetWorkspacesParams{
7176
AgentInactiveDisconnectTimeoutSeconds: int64(agentInactiveDisconnectTimeout.Seconds()),
7277

7378
Offset: int32(page.Offset),
7479
Limit: int32(page.Limit),
7580
}
7681

82+
var postFilter PostFilter
83+
7784
if query == "" {
78-
return filter, nil
85+
return filter, postFilter, nil
7986
}
8087

8188
// Always lowercase for all searches.
@@ -95,7 +102,7 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
95102
return nil
96103
})
97104
if len(errors) > 0 {
98-
return filter, errors
105+
return filter, postFilter, errors
99106
}
100107

101108
parser := httpapi.NewQueryParamParser()
@@ -104,8 +111,13 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
104111
filter.Name = parser.String(values, "", "name")
105112
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
106113
filter.HasAgent = parser.String(values, "", "has-agent")
114+
115+
if _, ok := values["deleting_by"]; ok {
116+
postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02"))
117+
}
118+
107119
parser.ErrorExcessParams(values)
108-
return filter, parser.Errors
120+
return filter, postFilter, parser.Errors
109121
}
110122

111123
func searchTerms(query string, defaultKey func(term string, values url.Values) error) (url.Values, []codersdk.ValidationError) {

coderd/searchquery/search_test.go

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@ import (
66
"testing"
77
"time"
88

9+
"github.com/stretchr/testify/assert"
910
"github.com/stretchr/testify/require"
1011

1112
"github.com/coder/coder/coderd/database"
1213
"github.com/coder/coder/coderd/rbac"
1314
"github.com/coder/coder/coderd/searchquery"
15+
"github.com/coder/coder/coderd/util/ptr"
1416
"github.com/coder/coder/codersdk"
1517
)
1618

@@ -148,17 +150,18 @@ func TestSearchWorkspace(t *testing.T) {
148150
c := c
149151
t.Run(c.Name, func(t *testing.T) {
150152
t.Parallel()
151-
values, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
153+
values, postFilter, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
152154
if c.ExpectedErrorContains != "" {
153-
require.True(t, len(errs) > 0, "expect some errors")
155+
assert.True(t, len(errs) > 0, "expect some errors")
154156
var s strings.Builder
155157
for _, err := range errs {
156158
_, _ = s.WriteString(fmt.Sprintf("%s: %s\n", err.Field, err.Detail))
157159
}
158-
require.Contains(t, s.String(), c.ExpectedErrorContains)
160+
assert.Contains(t, s.String(), c.ExpectedErrorContains)
159161
} else {
160-
require.Len(t, errs, 0, "expected no error")
161-
require.Equal(t, c.Expected, values, "expected values")
162+
assert.Empty(t, postFilter)
163+
assert.Len(t, errs, 0, "expected no error")
164+
assert.Equal(t, c.Expected, values, "expected values")
162165
}
163166
})
164167
}
@@ -167,10 +170,51 @@ func TestSearchWorkspace(t *testing.T) {
167170

168171
query := ``
169172
timeout := 1337 * time.Second
170-
values, errs := searchquery.Workspaces(query, codersdk.Pagination{}, timeout)
173+
values, _, errs := searchquery.Workspaces(query, codersdk.Pagination{}, timeout)
171174
require.Empty(t, errs)
172175
require.Equal(t, int64(timeout.Seconds()), values.AgentInactiveDisconnectTimeoutSeconds)
173176
})
177+
178+
t.Run("TestSearchWorkspacePostFilter", func(t *testing.T) {
179+
t.Parallel()
180+
testCases := []struct {
181+
Name string
182+
Query string
183+
Expected searchquery.PostFilter
184+
}{
185+
{
186+
Name: "Empty",
187+
Query: "",
188+
Expected: searchquery.PostFilter{},
189+
},
190+
{
191+
Name: "DeletingBy",
192+
Query: "deleting_by:2023-06-09",
193+
Expected: searchquery.PostFilter{
194+
DeletingBy: ptr.Ref(time.Date(
195+
2023, 6, 9, 0, 0, 0, 0, time.UTC)),
196+
},
197+
},
198+
{
199+
Name: "MultipleParams",
200+
Query: "deleting_by:2023-06-09 name:workspace-name",
201+
Expected: searchquery.PostFilter{
202+
DeletingBy: ptr.Ref(time.Date(
203+
2023, 6, 9, 0, 0, 0, 0, time.UTC)),
204+
},
205+
},
206+
}
207+
208+
for _, c := range testCases {
209+
c := c
210+
t.Run(c.Name, func(t *testing.T) {
211+
t.Parallel()
212+
_, postFilter, errs := searchquery.Workspaces(c.Query, codersdk.Pagination{}, 0)
213+
assert.Len(t, errs, 0, "expected no error")
214+
assert.Equal(t, c.Expected, postFilter, "expected values")
215+
})
216+
}
217+
})
174218
}
175219

176220
func TestSearchAudit(t *testing.T) {

coderd/workspaces.go

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@ func (api *API) workspace(rw http.ResponseWriter, r *http.Request) {
106106
// @Param name query string false "Filter with partial-match by workspace name"
107107
// @Param status query string false "Filter by workspace status" Enums(pending,running,stopping,stopped,failed,canceling,canceled,deleted,deleting)
108108
// @Param has_agent query string false "Filter by agent status" Enums(connected,connecting,disconnected,timeout)
109+
// @Param deleting_by query string false "Filter workspaces scheduled to be deleted by this time"
109110
// @Success 200 {object} codersdk.WorkspacesResponse
110111
// @Router /workspaces [get]
111112
func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
@@ -118,7 +119,7 @@ func (api *API) workspaces(rw http.ResponseWriter, r *http.Request) {
118119
}
119120

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

182+
var filteredWorkspaces []codersdk.Workspace
183+
// apply post filters, if they exist
184+
if postFilter.DeletingBy == nil {
185+
filteredWorkspaces = append(filteredWorkspaces, wss...)
186+
} else {
187+
for _, v := range wss {
188+
if v.DeletingAt == nil {
189+
continue
190+
}
191+
// get the beginning of the day on which deletion is scheduled
192+
truncatedDeletionAt := time.Date(v.DeletingAt.Year(), v.DeletingAt.Month(), v.DeletingAt.Day(), 0, 0, 0, 0, v.DeletingAt.Location())
193+
if truncatedDeletionAt.After(*postFilter.DeletingBy) {
194+
continue
195+
}
196+
filteredWorkspaces = append(filteredWorkspaces, v)
197+
}
198+
}
199+
181200
httpapi.Write(ctx, rw, http.StatusOK, codersdk.WorkspacesResponse{
182-
Workspaces: wss,
201+
Workspaces: filteredWorkspaces,
183202
Count: int(workspaceRows[0].Count),
184203
})
185204
}

coderd/workspaces_test.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"os"
1111
"strings"
12+
"sync/atomic"
1213
"testing"
1314
"time"
1415

@@ -1209,6 +1210,62 @@ func TestWorkspaceFilterManual(t *testing.T) {
12091210
return workspaces.Count == 1
12101211
}, testutil.IntervalMedium, "agent status timeout")
12111212
})
1213+
1214+
t.Run("FilterQueryHasDeletingByAndUnlicensed", func(t *testing.T) {
1215+
// this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed
1216+
t.Parallel()
1217+
inactivityTTL := 1 * 24 * time.Hour
1218+
var setCalled int64
1219+
1220+
client := coderdtest.New(t, &coderdtest.Options{
1221+
IncludeProvisionerDaemon: true,
1222+
TemplateScheduleStore: schedule.MockTemplateScheduleStore{
1223+
SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) {
1224+
if atomic.AddInt64(&setCalled, 1) == 2 {
1225+
assert.Equal(t, inactivityTTL, options.InactivityTTL)
1226+
}
1227+
template.InactivityTTL = int64(options.InactivityTTL)
1228+
return template, nil
1229+
},
1230+
},
1231+
})
1232+
user := coderdtest.CreateFirstUser(t, client)
1233+
authToken := uuid.NewString()
1234+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
1235+
Parse: echo.ParseComplete,
1236+
ProvisionPlan: echo.ProvisionComplete,
1237+
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
1238+
})
1239+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
1240+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
1241+
1242+
// update template with inactivity ttl
1243+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1244+
defer cancel()
1245+
1246+
template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
1247+
InactivityTTLMillis: inactivityTTL.Milliseconds(),
1248+
})
1249+
1250+
assert.NoError(t, err)
1251+
assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis)
1252+
1253+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
1254+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
1255+
1256+
// stop build so workspace is inactive
1257+
stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
1258+
coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID)
1259+
1260+
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
1261+
FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(inactivityTTL).Format("2006-01-02")),
1262+
})
1263+
1264+
assert.NoError(t, err)
1265+
// we are expecting that no workspaces are returned as user is unlicensed
1266+
// and template.InactivityTTL should be 0
1267+
assert.Len(t, res.Workspaces, 0)
1268+
})
12121269
}
12131270

12141271
func TestOffsetLimit(t *testing.T) {

docs/api/workspaces.md

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -383,13 +383,14 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \
383383

384384
### Parameters
385385

386-
| Name | In | Type | Required | Description |
387-
| ----------- | ----- | ------ | -------- | ------------------------------------------- |
388-
| `owner` | query | string | false | Filter by owner username |
389-
| `template` | query | string | false | Filter by template name |
390-
| `name` | query | string | false | Filter with partial-match by workspace name |
391-
| `status` | query | string | false | Filter by workspace status |
392-
| `has_agent` | query | string | false | Filter by agent status |
386+
| Name | In | Type | Required | Description |
387+
| ------------- | ----- | ------ | -------- | ------------------------------------------------------ |
388+
| `owner` | query | string | false | Filter by owner username |
389+
| `template` | query | string | false | Filter by template name |
390+
| `name` | query | string | false | Filter with partial-match by workspace name |
391+
| `status` | query | string | false | Filter by workspace status |
392+
| `has_agent` | query | string | false | Filter by agent status |
393+
| `deleting_by` | query | string | false | Filter workspaces scheduled to be deleted by this time |
393394

394395
#### Enumerated Values
395396

enterprise/coderd/workspaces_test.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ package coderd_test
22

33
import (
44
"context"
5+
"fmt"
56
"net/http"
67
"testing"
78
"time"
89

10+
"github.com/stretchr/testify/assert"
911
"github.com/stretchr/testify/require"
1012

1113
"github.com/coder/coder/coderd/coderdtest"
14+
"github.com/coder/coder/coderd/database"
1215
"github.com/coder/coder/coderd/util/ptr"
1316
"github.com/coder/coder/codersdk"
1417
"github.com/coder/coder/enterprise/coderd/coderdenttest"
@@ -70,3 +73,56 @@ func TestCreateWorkspace(t *testing.T) {
7073
require.Error(t, err)
7174
})
7275
}
76+
77+
func TestWorkspacesFiltering(t *testing.T) {
78+
t.Parallel()
79+
80+
t.Run("FilterQueryHasDeletingByAndLicensed", func(t *testing.T) {
81+
t.Parallel()
82+
83+
inactivityTTL := 1 * 24 * time.Hour
84+
85+
client := coderdenttest.New(t, &coderdenttest.Options{
86+
Options: &coderdtest.Options{
87+
IncludeProvisionerDaemon: true,
88+
},
89+
})
90+
user := coderdtest.CreateFirstUser(t, client)
91+
_ = coderdenttest.AddLicense(t, client, coderdenttest.LicenseOptions{
92+
Features: license.Features{
93+
codersdk.FeatureAdvancedTemplateScheduling: 1,
94+
},
95+
})
96+
97+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
98+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
99+
100+
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
101+
102+
// update template with inactivity ttl
103+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
104+
defer cancel()
105+
106+
template, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
107+
InactivityTTLMillis: inactivityTTL.Milliseconds(),
108+
})
109+
110+
assert.NoError(t, err)
111+
assert.Equal(t, inactivityTTL.Milliseconds(), template.InactivityTTLMillis)
112+
113+
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
114+
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
115+
116+
// stop build so workspace is inactive
117+
stopBuild := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop)
118+
coderdtest.AwaitWorkspaceBuildJob(t, client, stopBuild.ID)
119+
120+
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
121+
// adding a second to time.Now() to give some buffer in case test runs quickly
122+
FilterQuery: fmt.Sprintf("deleting_by:%s", time.Now().Add(time.Second).Add(inactivityTTL).Format("2006-01-02")),
123+
})
124+
assert.NoError(t, err)
125+
assert.Len(t, res.Workspaces, 1)
126+
assert.Equal(t, workspace.ID, res.Workspaces[0].ID)
127+
})
128+
}

0 commit comments

Comments
 (0)