Skip to content

feat: add frontend for locked workspaces #8655

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 16 commits into from
Aug 4, 2023
Merged
2 changes: 1 addition & 1 deletion coderd/apidoc/docs.go

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

2 changes: 1 addition & 1 deletion coderd/apidoc/swagger.json

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

2 changes: 1 addition & 1 deletion coderd/autobuild/lifecycle_executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ func (e *Executor) runOnce(t time.Time) Stats {
// Lock the workspace if it has breached the template's
// threshold for inactivity.
if reason == database.BuildReasonAutolock {
err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{
ws, err = tx.UpdateWorkspaceLockedDeletingAt(e.ctx, database.UpdateWorkspaceLockedDeletingAtParams{
ID: ws.ID,
LockedAt: sql.NullTime{
Time: database.Now(),
Expand Down
4 changes: 2 additions & 2 deletions coderd/database/dbauthz/dbauthz.go
Original file line number Diff line number Diff line change
Expand Up @@ -2561,11 +2561,11 @@ func (q *querier) UpdateWorkspaceLastUsedAt(ctx context.Context, arg database.Up
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLastUsedAt)(ctx, arg)
}

func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error {
func (q *querier) UpdateWorkspaceLockedDeletingAt(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
fetch := func(ctx context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
return q.db.GetWorkspaceByID(ctx, arg.ID)
}
return update(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg)
return updateWithReturn(q.log, q.auth, fetch, q.db.UpdateWorkspaceLockedDeletingAt)(ctx, arg)
}

func (q *querier) UpdateWorkspaceProxy(ctx context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
Expand Down
20 changes: 15 additions & 5 deletions coderd/database/dbfake/dbfake.go
Original file line number Diff line number Diff line change
Expand Up @@ -5113,9 +5113,9 @@ func (q *FakeQuerier) UpdateWorkspaceLastUsedAt(_ context.Context, arg database.
return sql.ErrNoRows
}

func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) error {
func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg database.UpdateWorkspaceLockedDeletingAtParams) (database.Workspace, error) {
if err := validateDatabaseType(arg); err != nil {
return err
return database.Workspace{}, err
}
q.mutex.Lock()
defer q.mutex.Unlock()
Expand All @@ -5137,7 +5137,7 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat
}
}
if template.ID == uuid.Nil {
return xerrors.Errorf("unable to find workspace template")
return database.Workspace{}, xerrors.Errorf("unable to find workspace template")
}
if template.LockedTTL > 0 {
workspace.DeletingAt = sql.NullTime{
Expand All @@ -5147,9 +5147,9 @@ func (q *FakeQuerier) UpdateWorkspaceLockedDeletingAt(_ context.Context, arg dat
}
}
q.workspaces[index] = workspace
return nil
return workspace, nil
}
return sql.ErrNoRows
return database.Workspace{}, sql.ErrNoRows
}

func (q *FakeQuerier) UpdateWorkspaceProxy(_ context.Context, arg database.UpdateWorkspaceProxyParams) (database.WorkspaceProxy, error) {
Expand Down Expand Up @@ -5593,6 +5593,16 @@ func (q *FakeQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg database.
}
}

// We omit locked workspaces by default.
if arg.LockedAt.IsZero() && workspace.LockedAt.Valid {
continue
}

// Filter out workspaces that are locked after the timestamp.
if !arg.LockedAt.IsZero() && workspace.LockedAt.Time.Before(arg.LockedAt) {
continue
}

if len(arg.TemplateIDs) > 0 {
match := false
for _, id := range arg.TemplateIDs {
Expand Down
6 changes: 3 additions & 3 deletions coderd/database/dbmetrics/dbmetrics.go

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

7 changes: 4 additions & 3 deletions coderd/database/dbmock/dbmock.go

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

1 change: 1 addition & 0 deletions coderd/database/modelqueries.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,7 @@ func (q *sqlQuerier) GetAuthorizedWorkspaces(ctx context.Context, arg GetWorkspa
arg.Name,
arg.HasAgent,
arg.AgentInactiveDisconnectTimeoutSeconds,
arg.LockedAt,
arg.Offset,
arg.Limit,
)
Expand Down
2 changes: 1 addition & 1 deletion coderd/database/querier.go

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

41 changes: 34 additions & 7 deletions coderd/database/queries.sql.go

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

13 changes: 11 additions & 2 deletions coderd/database/queries/workspaces.sql
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,14 @@ WHERE
) > 0
ELSE true
END
-- Filter by locked workspaces. By default we do not return locked
-- workspaces since they are considered soft-deleted.
AND CASE
WHEN @locked_at :: timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN
locked_at IS NOT NULL AND locked_at >= @locked_at
ELSE
locked_at IS NULL
END
-- Authorize Filter clause will be injected below in GetAuthorizedWorkspaces
-- @authorize_filter
ORDER BY
Expand Down Expand Up @@ -474,7 +482,7 @@ WHERE
)
) AND workspaces.deleted = 'false';

-- name: UpdateWorkspaceLockedDeletingAt :exec
-- name: UpdateWorkspaceLockedDeletingAt :one
UPDATE
workspaces
SET
Expand All @@ -490,7 +498,8 @@ FROM
WHERE
workspaces.template_id = templates.id
AND
workspaces.id = $1;
workspaces.id = $1
RETURNING workspaces.*;

-- name: UpdateWorkspacesDeletingAtByTemplateID :exec
UPDATE
Expand Down
6 changes: 6 additions & 0 deletions coderd/searchquery/search.go
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,15 @@ 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")
filter.LockedAt = parser.Time(values, time.Time{}, "locked_at", "2006-01-02")

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

parser.ErrorExcessParams(values)
Expand Down
26 changes: 17 additions & 9 deletions coderd/workspaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -768,7 +768,7 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) {
// @Tags Workspaces
// @Param workspace path string true "Workspace ID" format(uuid)
// @Param request body codersdk.UpdateWorkspaceLock true "Lock or unlock a workspace"
// @Success 200 {object} codersdk.Response
// @Success 200 {object} codersdk.Workspace
// @Router /workspaces/{workspace}/lock [put]
func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
Expand All @@ -779,9 +779,6 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
return
}

code := http.StatusOK
resp := codersdk.Response{}

// If the workspace is already in the desired state do nothing!
if workspace.LockedAt.Valid == req.Lock {
httpapi.Write(ctx, rw, http.StatusNotModified, codersdk.Response{
Expand All @@ -797,7 +794,7 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
lockedAt.Time = database.Now()
}

err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{
workspace, err := api.Database.UpdateWorkspaceLockedDeletingAt(ctx, database.UpdateWorkspaceLockedDeletingAtParams{
ID: workspace.ID,
LockedAt: lockedAt,
})
Expand All @@ -809,10 +806,21 @@ func (api *API) putWorkspaceLock(rw http.ResponseWriter, r *http.Request) {
return
}

// TODO should we kick off a build to stop the workspace if it's started
// from this endpoint? I'm leaning no to keep things simple and kick
// the responsibility back to the client.
httpapi.Write(ctx, rw, code, resp)
data, err := api.workspaceData(ctx, []database.Workspace{workspace})
if err != nil {
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
Message: "Internal error fetching workspace resources.",
Detail: err.Error(),
})
return
}

httpapi.Write(ctx, rw, http.StatusOK, convertWorkspace(
workspace,
data.builds[0],
data.templates[0],
findUser(workspace.OwnerID, data.users),
))
}

// @Summary Extend workspace deadline by ID
Expand Down
40 changes: 40 additions & 0 deletions coderd/workspaces_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1407,6 +1407,46 @@ func TestWorkspaceFilterManual(t *testing.T) {
// and template.InactivityTTL should be 0
assert.Len(t, res.Workspaces, 0)
})

t.Run("LockedAt", func(t *testing.T) {
// this test has a licensed counterpart in enterprise/coderd/workspaces_test.go: FilterQueryHasDeletingByAndLicensed
t.Parallel()
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
})
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()

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

// Create another workspace to validate that we do not return unlocked workspaces.
_ = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
_ = coderdtest.AwaitWorkspaceBuildJob(t, client, lockedWorkspace.LatestBuild.ID)

err := client.UpdateWorkspaceLock(ctx, lockedWorkspace.ID, codersdk.UpdateWorkspaceLock{
Lock: true,
})
require.NoError(t, err)

res, err := client.Workspaces(ctx, codersdk.WorkspaceFilter{
FilterQuery: fmt.Sprintf("locked_at:%s", time.Now().Add(-time.Minute).Format("2006-01-02")),
})
require.NoError(t, err)
require.Len(t, res.Workspaces, 1)
require.NotNil(t, res.Workspaces[0].LockedAt)
})
}

func TestOffsetLimit(t *testing.T) {
Expand Down
Loading