Skip to content

Commit e436083

Browse files
authored
feat: add frontend for locked workspaces (#8655)
- Fix workspaces query for locked workspaces.
1 parent 502c768 commit e436083

36 files changed

+664
-192
lines changed

coderd/apidoc/docs.go

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/autobuild/lifecycle_executor.go

+1-1
Original file line numberDiff line numberDiff line change
@@ -178,7 +178,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
178178
// Lock the workspace if it has breached the template's
179179
// threshold for inactivity.
180180
if reason == database.BuildReasonAutolock {
181-
err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{
181+
ws, err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{
182182
ID: ws.ID,
183183
LockedAt: sql.NullTime{
184184
Time: database.Now(),

coderd/database/dbauthz/dbauthz.go

+2-2
Original file line numberDiff line numberDiff line change
@@ -2587,11 +2587,11 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up
25872587
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg)
25882588
}
25892589

2590-
func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error {
2590+
func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
25912591
fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
25922592
return q.db.GetWorkspaceByID(ctx, arg.ID)
25932593
}
2594-
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg)
2594+
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg)
25952595
}
25962596

25972597
func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {

coderd/database/dbfake/dbfake.go

+15-5
Original file line numberDiff line numberDiff line change
@@ -5250,9 +5250,9 @@ func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.
52505250
return sql.ErrNoRows
52515251
}
52525252

5253-
func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error {
5253+
func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
52545254
if err := validateDatabaseType(arg); err != nil {
5255-
return err
5255+
return database.Workspace{}, err
52565256
}
52575257
q.mutex.Lock()
52585258
defer q.mutex.Unlock()
@@ -5274,7 +5274,7 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat
52745274
}
52755275
}
52765276
if template.ID == uuid.Nil {
5277-
return xerrors.Errorf("unable to find workspace template")
5277+
return database.Workspace{}, xerrors.Errorf("unable to find workspace template")
52785278
}
52795279
if template.LockedTTL > 0 {
52805280
workspace.DeletingAt = sql.NullTime{
@@ -5284,9 +5284,9 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat
52845284
}
52855285
}
52865286
q.workspaces[index] = workspace
5287-
return nil
5287+
return workspace, nil
52885288
}
5289-
return sql.ErrNoRows
5289+
return database.Workspace{}, sql.ErrNoRows
52905290
}
52915291

52925292
func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
@@ -5730,6 +5730,16 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
57305730
}
57315731
}
57325732

5733+
// We omit locked workspaces by default.
5734+
if arg.LockedAt.IsZero() && workspace.LockedAt.Valid {
5735+
continue
5736+
}
5737+
5738+
// Filter out workspaces that are locked after the timestamp.
5739+
if !arg.LockedAt.IsZero() && workspace.LockedAt.Time.Before(arg.LockedAt) {
5740+
continue
5741+
}
5742+
57335743
if len(arg.TemplateIDs) > 0 {
57345744
match := false
57355745
for _, id := range arg.TemplateIDs {

coderd/database/dbmetrics/dbmetrics.go

+3-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/dbmock/dbmock.go

+4-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/modelqueries.go

+1
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
217217
arg.Name,
218218
arg.HasAgent,
219219
arg.AgentInactiveDisconnectTimeoutSeconds,
220+
arg.LockedAt,
220221
arg.Offset,
221222
arg.Limit,
222223
)

coderd/database/querier.go

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries.sql.go

+34-7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspaces.sql

+11-2
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,14 @@ WHERE
259259
) > 0
260260
ELSE true
261261
END
262+
-- Filter by locked workspaces. By default we do not return locked
263+
-- workspaces since they are considered soft-deleted.
264+
AND CASE
265+
WHEN @locked_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN
266+
locked_at IS NOT NULL AND locked_at >= @locked_at
267+
ELSE
268+
locked_at IS NULL
269+
END
262270
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
263271
-- @authorize_filter
264272
ORDER BY
@@ -474,7 +482,7 @@ WHERE
474482
)
475483
) AND workspaces.deleted = 'false';
476484

477-
-- name: UpdateWorkspaceLockedDeletingAt :exec
485+
-- name: UpdateWorkspaceLockedDeletingAt :one
478486
UPDATE
479487
workspaces
480488
SET
@@ -490,7 +498,8 @@ FROM
490498
WHERE
491499
workspaces.template_id = templates.id
492500
AND
493-
workspaces.id = $1;
501+
workspaces.id = $1
502+
RETURNING workspaces.*;
494503

495504
-- name: UpdateWorkspacesDeletingAtByTemplateID :exec
496505
UPDATE

coderd/searchquery/search.go

+6
Original file line numberDiff line numberDiff line change
@@ -114,9 +114,15 @@ func Workspaces(query string, page codersdk.Pagination, agentInactiveDisconnectT
114114
filter.Name = parser.String(values, "", "name")
115115
filter.Status = string(httpapi.ParseCustom(parser, values, "", "status", httpapi.ParseEnum[database.WorkspaceStatus]))
116116
filter.HasAgent = parser.String(values, "", "has-agent")
117+
filter.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02")
117118

118119
if _, ok := values["deleting_by"]; ok {
119120
postFilter.DeletingBy = ptr.Ref(parser.Time(values, time.Time{}, "deleting_by", "2006-01-02"))
121+
// We want to make sure to grab locked workspaces since they
122+
// are omitted by default.
123+
if filter.LockedAt.IsZero() {
124+
filter.LockedAt = time.Date(1970, time.January, 1, 0, 0, 0, 0, time.UTC)
125+
}
120126
}
121127

122128
parser.ErrorExcessParams(values)

coderd/workspaces.go

+17-9
Original file line numberDiff line numberDiff line change
@@ -768,7 +768,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
768768
// @Tags Workspaces
769769
// @Param workspace path string true "Workspace ID" format(uuid)
770770
// @Param request body codersdk.UpdateWorkspaceLock true "Lock or unlock a workspace"
771-
// @Success 200 {object} codersdk.Response
771+
// @Success 200 {object} codersdk.Workspace
772772
// @Router /workspaces/{workspace}/lock [put]
773773
func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
774774
ctx := r.Context()
@@ -779,9 +779,6 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
779779
return
780780
}
781781

782-
code := http.StatusOK
783-
resp := codersdk.Response{}
784-
785782
// If the workspace is already in the desired state do nothing!
786783
if workspace.LockedAt.Valid == req.Lock {
787784
httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{
@@ -797,7 +794,7 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
797794
lockedAt.Time = database.Now()
798795
}
799796

800-
err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{
797+
workspace, err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{
801798
ID: workspace.ID,
802799
LockedAt: lockedAt,
803800
})
@@ -809,10 +806,21 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
809806
return
810807
}
811808

812-
// TODO should we kick off a build to stop the workspace if it's started
813-
// from this endpoint? I'm leaning no to keep things simple and kick
814-
// the responsibility back to the client.
815-
httpapi.Write(ctx, rw, code, resp)
809+
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
810+
if err != nil {
811+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
812+
Message: "Internal error fetching workspace resources.",
813+
Detail: err.Error(),
814+
})
815+
return
816+
}
817+
818+
httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace(
819+
workspace,
820+
data.builds[0],
821+
data.templates[0],
822+
findUser(workspace.OwnerID, data.users),
823+
))
816824
}
817825

818826
// @Summary Extend workspace deadline by ID

coderd/workspaces_test.go

+40
Original file line numberDiff line numberDiff line change
@@ -1407,6 +1407,46 @@ func TestWorkspaceFilterManual(t *testing.T) {
14071407
// and template.InactivityTTL should be 0
14081408
assert.Len(t, res.Workspaces, 0)
14091409
})
1410+
1411+
t.Run("LockedAt", func(t *testing.T) {
1412+
// this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed
1413+
t.Parallel()
1414+
client := coderdtest.New(t, &coderdtest.Options{
1415+
IncludeProvisionerDaemon: true,
1416+
})
1417+
user := coderdtest.CreateFirstUser(t, client)
1418+
authToken := uuid.NewString()
1419+
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{
1420+
Parse: echo.ParseComplete,
1421+
ProvisionPlan: echo.ProvisionComplete,
1422+
ProvisionApply: echo.ProvisionApplyWithAgent(authToken),
1423+
})
1424+
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
1425+
_ = coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
1426+
1427+
// update template with inactivity ttl
1428+
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
1429+
defer cancel()
1430+
1431+
lockedWorkspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
1432+
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)
1433+
1434+
// Create another workspace to validate that we do not return unlocked workspaces.
1435+
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
1436+
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)
1437+
1438+
err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{
1439+
Lock: true,
1440+
})
1441+
require.NoError(t, err)
1442+
1443+
res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
1444+
FilterQuery: fmt.Sprintf("locked_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")),
1445+
})
1446+
require.NoError(t, err)
1447+
require.Len(t, res.Workspaces, 1)
1448+
require.NotNil(t, res.Workspaces[0].LockedAt)
1449+
})
14101450
}
14111451

14121452
func TestOffsetLimit(t *testing.T) {

0 commit comments

Comments
 (0)