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",
Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.
Alternative Proxies: