feat: bump workspace deadline on user activity (#4119)

Resolves #2995
This commit is contained in:
Ammar Bandukwala 2022-09-20 16:17:24 -05:00 committed by GitHub
parent 0899548208
commit d30945c5c5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 189 additions and 6 deletions

79
coderd/activitybump.go Normal file
View File

@ -0,0 +1,79 @@
package coderd
import (
"context"
"database/sql"
"errors"
"time"
"golang.org/x/xerrors"
"cdr.dev/slog"
"github.com/coder/coder/coderd/database"
)
// activityBumpWorkspace automatically bumps the workspace's auto-off timer
// if it is set to expire soon.
func activityBumpWorkspace(log slog.Logger, db database.Store, workspace database.Workspace) {
// We set a short timeout so if the app is under load, these
// low priority operations fail first.
ctx, cancel := context.WithTimeout(context.Background(), time.Second*15)
defer cancel()
err := db.InTx(func(s database.Store) error {
build, err := s.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
if errors.Is(err, sql.ErrNoRows) {
return nil
} else if err != nil {
return xerrors.Errorf("get latest workspace build: %w", err)
}
job, err := s.GetProvisionerJobByID(ctx, build.JobID)
if err != nil {
return xerrors.Errorf("get provisioner job: %w", err)
}
if build.Transition != database.WorkspaceTransitionStart || !job.CompletedAt.Valid {
return nil
}
if build.Deadline.IsZero() {
// Workspace shutdown is manual
return nil
}
// We sent bumpThreshold slightly under bumpAmount to minimize DB writes.
const (
bumpAmount = time.Hour
bumpThreshold = time.Hour - (time.Minute * 10)
)
if !build.Deadline.Before(time.Now().Add(bumpThreshold)) {
return nil
}
newDeadline := database.Now().Add(bumpAmount)
if err := s.UpdateWorkspaceBuildByID(ctx, database.UpdateWorkspaceBuildByIDParams{
ID: build.ID,
UpdatedAt: database.Now(),
ProvisionerState: build.ProvisionerState,
Deadline: newDeadline,
}); err != nil {
return xerrors.Errorf("update workspace build: %w", err)
}
return nil
})
if err != nil {
log.Error(
ctx, "bump failed",
slog.Error(err),
slog.F("workspace_id", workspace.ID),
)
} else {
log.Debug(
ctx, "bumped deadline from activity",
slog.F("workspace_id", workspace.ID),
)
}
}

100
coderd/activitybump_test.go Normal file
View File

@ -0,0 +1,100 @@
package coderd_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
"cdr.dev/slog/sloggers/slogtest"
"github.com/coder/coder/coderd/coderdtest"
"github.com/coder/coder/coderd/database"
"github.com/coder/coder/codersdk"
"github.com/coder/coder/testutil"
)
func TestWorkspaceActivityBump(t *testing.T) {
t.Parallel()
ctx := context.Background()
setupActivityTest := func(t *testing.T) (client *codersdk.Client, workspace codersdk.Workspace, assertBumped func(want bool)) {
var ttlMillis int64 = 60 * 1000
client, _, workspace, _ = setupProxyTest(t, func(cwr *codersdk.CreateWorkspaceRequest) {
cwr.TTLMillis = &ttlMillis
})
// Sanity-check that deadline is near.
workspace, err := client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.WithinDuration(t,
time.Now().Add(time.Duration(ttlMillis)*time.Millisecond),
workspace.LatestBuild.Deadline.Time, testutil.WaitShort,
)
firstDeadline := workspace.LatestBuild.Deadline.Time
_ = coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
return client, workspace, func(want bool) {
if !want {
// It is difficult to test the absence of a call in a non-racey
// way. In general, it is difficult for the API to generate
// false positive activity since Agent networking event
// is required. The Activity Bump behavior is also coupled with
// Last Used, so it would be obvious to the user if we
// are falsely recognizing activity.
time.Sleep(testutil.IntervalMedium)
workspace, err = client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
require.Equal(t, workspace.LatestBuild.Deadline.Time, firstDeadline)
return
}
// The Deadline bump occurs asynchronously.
require.Eventuallyf(t,
func() bool {
workspace, err = client.Workspace(ctx, workspace.ID)
require.NoError(t, err)
return workspace.LatestBuild.Deadline.Time != firstDeadline
},
testutil.WaitShort, testutil.IntervalFast,
"deadline %v never updated", firstDeadline,
)
require.WithinDuration(t, database.Now().Add(time.Hour), workspace.LatestBuild.Deadline.Time, time.Second)
}
}
t.Run("Dial", func(t *testing.T) {
t.Parallel()
client, workspace, assertBumped := setupActivityTest(t)
resources := coderdtest.AwaitWorkspaceAgents(t, client, workspace.LatestBuild.ID)
conn, err := client.DialWorkspaceAgentTailnet(ctx, slogtest.Make(t, nil), resources[0].Agents[0].ID)
require.NoError(t, err)
defer conn.Close()
sshConn, err := conn.SSHClient()
require.NoError(t, err)
_ = sshConn.Close()
assertBumped(true)
})
t.Run("NoBump", func(t *testing.T) {
t.Parallel()
client, workspace, assertBumped := setupActivityTest(t)
// Benign operations like retrieving resources must not
// bump the deadline.
_, err := client.WorkspaceResourcesByBuild(ctx, workspace.LatestBuild.ID)
require.NoError(t, err)
assertBumped(false)
})
}

View File

@ -616,6 +616,8 @@ func (api *API) workspaceAgentReportStats(rw http.ResponseWriter, r *http.Reques
)
if updateDB {
go activityBumpWorkspace(api.Logger.Named("activity_bump"), api.Database, workspace)
lastReport = rep
_, err = api.Database.InsertAgentStat(ctx, database.InsertAgentStatParams{

View File

@ -36,7 +36,7 @@ const (
// setupProxyTest creates a workspace with an agent and some apps. It returns a
// codersdk client, the workspace, and the port number the test listener is
// running on.
func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspace, uint16) {
func setupProxyTest(t *testing.T, workspaceMutators ...func(*codersdk.CreateWorkspaceRequest)) (*codersdk.Client, uuid.UUID, codersdk.Workspace, uint16) {
// #nosec
ln, err := net.Listen("tcp", ":0")
require.NoError(t, err)
@ -58,7 +58,9 @@ func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspa
require.True(t, ok)
client := coderdtest.New(t, &coderdtest.Options{
IncludeProvisionerDaemon: true,
IncludeProvisionerDaemon: true,
AgentStatsRefreshInterval: time.Millisecond * 100,
MetricsCacheRefreshInterval: time.Millisecond * 100,
})
user := coderdtest.CreateFirstUser(t, client)
authToken := uuid.NewString()
@ -95,7 +97,7 @@ func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspa
})
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
coderdtest.AwaitTemplateVersionJob(t, client, version.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID)
workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID, workspaceMutators...)
coderdtest.AwaitWorkspaceBuildJob(t, client, workspace.LatestBuild.ID)
agentClient := codersdk.New(client.URL)
@ -104,6 +106,7 @@ func setupProxyTest(t *testing.T) (*codersdk.Client, uuid.UUID, codersdk.Workspa
FetchMetadata: agentClient.WorkspaceAgentMetadata,
CoordinatorDialer: agentClient.ListenWorkspaceAgentTailnet,
Logger: slogtest.Make(t, nil).Named("agent"),
StatsReporter: agentClient.AgentReportStats,
})
t.Cleanup(func() {
_ = agentCloser.Close()

View File

@ -281,12 +281,10 @@ func (c *Client) DialWorkspaceAgentTailnet(ctx context.Context, logger slog.Logg
CompressionMode: websocket.CompressionDisabled,
})
if errors.Is(err, context.Canceled) {
_ = ws.Close(websocket.StatusAbnormalClosure, "")
return
}
if err != nil {
logger.Debug(ctx, "failed to dial", slog.Error(err))
_ = ws.Close(websocket.StatusAbnormalClosure, "")
continue
}
sendNode, errChan := tailnet.ServeCoordinator(websocket.NetConn(ctx, ws, websocket.MessageBinary), func(node []*tailnet.Node) error {

View File

@ -55,7 +55,8 @@ export const Language = {
timezoneLabel: "Timezone",
ttlLabel: "Time until shutdown (hours)",
ttlCausesShutdownHelperText: "Your workspace will shut down",
ttlCausesShutdownAfterStart: "after its next start",
ttlCausesShutdownAfterStart:
"after its next start. We delay shutdown by an hour whenever we detect activity",
ttlCausesNoShutdownHelperText: "Your workspace will not automatically shut down.",
formTitle: "Workspace schedule",
startSection: "Start",