diff --git a/coderd/activitybump.go b/coderd/activitybump.go new file mode 100644 index 0000000000000..fa90f7d275daf --- /dev/null +++ b/coderd/activitybump.go @@ -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), + ) + } +} diff --git a/coderd/activitybump_test.go b/coderd/activitybump_test.go new file mode 100644 index 0000000000000..39c2f5fc6c5c8 --- /dev/null +++ b/coderd/activitybump_test.go @@ -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) + }) +} diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 57c90c5b84479..219fbb78b7d00 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -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{ diff --git a/coderd/workspaceapps_test.go b/coderd/workspaceapps_test.go index 831b3761693df..164ceb2b15de6 100644 --- a/coderd/workspaceapps_test.go +++ b/coderd/workspaceapps_test.go @@ -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() diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 2117de03c6ce3..bf1f58eceafac 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -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 { diff --git a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx index 1b53583ed141c..49f9aec931975 100644 --- a/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx +++ b/site/src/components/WorkspaceScheduleForm/WorkspaceScheduleForm.tsx @@ -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",