diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 60954373c470f..9e7e5dd071f4e 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -11,7 +11,6 @@ import ( "sync/atomic" "time" - "github.com/dustin/go-humanize" "github.com/google/uuid" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" @@ -382,8 +381,10 @@ func (e *Executor) runOnce(t time.Time) Stats { Old: wsOld.WorkspaceTable(), New: wsNew, } - // To keep the `ws` accurate without doing a sql fetch + // Keep `ws` accurate without a sql fetch. The UPDATE derives + // deleting_at from the template's time_til_dormant_autodelete. ws.DormantAt = wsNew.DormantAt + ws.DeletingAt = wsNew.DeletingAt shouldNotifyDormancy = true @@ -467,7 +468,6 @@ func (e *Executor) runOnce(t time.Time) Stats { } } if shouldNotifyDormancy { - dormantTime := dbtime.Now().Add(time.Duration(tmpl.TimeTilDormant)) _, err = e.notificationsEnqueuer.Enqueue( e.ctx, ws.OwnerID, @@ -475,7 +475,7 @@ func (e *Executor) runOnce(t time.Time) Stats { map[string]string{ "name": ws.Name, "reason": "inactivity exceeded the dormancy threshold", - "timeTilDormant": humanize.Time(dormantTime), + "timeTilDormant": notifications.DormantDeletionText(ws.DeletingAt), }, "lifecycle_executor", ws.ID, diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 345647977d663..f3126dc2f712a 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -1336,6 +1336,83 @@ func TestNotifications(t *testing.T) { require.Contains(t, sent[0].Targets, workspace.ID) require.Contains(t, sent[0].Targets, workspace.OrganizationID) require.Contains(t, sent[0].Targets, workspace.OwnerID) + + require.Equal(t, "line with your template's auto-deletion policy", sent[0].Labels["timeTilDormant"]) + require.Equal(t, workspace.Name, sent[0].Labels["name"]) + require.Equal(t, "inactivity exceeded the dormancy threshold", sent[0].Labels["reason"]) + }) + + t.Run("DormancyAutoDelete", func(t *testing.T) { + t.Parallel() + + var ( + ticker = make(chan time.Time) + statCh = make(chan autobuild.Stats) + notifyEnq = notificationstest.FakeEnqueuer{} + // 35 days keeps humanize.Time deterministically in its "1 month" bucket. + timeTilDormant = time.Minute + timeTilDormantAutoDelete = 35 * 24 * time.Hour + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{ + AutobuildTicker: ticker, + AutobuildStats: statCh, + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: ¬ifyEnq, + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + template.TimeTilDormant = int64(options.TimeTilDormant) + template.TimeTilDormantAutoDelete = int64(options.TimeTilDormantAutoDelete) + return schedule.NewAGPLTemplateScheduleStore().Set(ctx, db, template, options) + }, + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: true, + DefaultTTL: 0, + AutostopRequirement: schedule.TemplateAutostopRequirement{}, + TimeTilDormant: timeTilDormant, + TimeTilDormantAutoDelete: timeTilDormantAutoDelete, + }, nil + }, + }, + }) + admin = coderdtest.CreateFirstUser(t, client) + version = coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) + ) + + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.TimeTilDormantMillis = ptr.Ref(timeTilDormant.Milliseconds()) + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref(timeTilDormantAutoDelete.Milliseconds()) + }) + userClient, _ := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + workspace := coderdtest.CreateWorkspace(t, userClient, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) + + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) + + p, err := coderdtest.GetProvisionerForTags(db, time.Now(), workspace.OrganizationID, nil) + require.NoError(t, err) + + notifyEnq.Clear() + tickTime := workspace.LastUsedAt.Add(timeTilDormant * 3) + coderdtest.UpdateProvisionerLastSeenAt(t, db, p.ID, tickTime) + ticker <- tickTime + _ = testutil.TryReceive(testutil.Context(t, testutil.WaitShort), t, statCh) + + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.NotNil(t, workspace.DormantAt) + + sent := notifyEnq.Sent() + require.Len(t, sent, 1) + require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceDormant) + require.Contains(t, sent[0].Labels, "timeTilDormant") + require.Contains(t, sent[0].Labels["timeTilDormant"], "1 month", + "timeTilDormant must humanize TimeTilDormantAutoDelete, got %q", + sent[0].Labels["timeTilDormant"]) + require.NotContains(t, sent[0].Labels["timeTilDormant"], "ago", + "timeTilDormant must be a future timestamp, got %q", + sent[0].Labels["timeTilDormant"]) }) } diff --git a/coderd/notifications/dormancy.go b/coderd/notifications/dormancy.go new file mode 100644 index 0000000000000..236bcd130e996 --- /dev/null +++ b/coderd/notifications/dormancy.go @@ -0,0 +1,16 @@ +package notifications + +import ( + "database/sql" + + "github.com/dustin/go-humanize" +) + +// DormantDeletionText supplies the timeTilDormant label embedded in the stored +// TemplateWorkspaceDormant "will be automatically deleted in ..." sentence. +func DormantDeletionText(deletingAt sql.NullTime) string { + if deletingAt.Valid { + return humanize.Time(deletingAt.Time) + } + return "line with your template's auto-deletion policy" +} diff --git a/coderd/workspaces.go b/coderd/workspaces.go index b812011edca75..2bbaaf5e65b5f 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -12,7 +12,6 @@ import ( "strings" "time" - "github.com/dustin/go-humanize" "github.com/go-chi/chi/v5" "github.com/google/uuid" "golang.org/x/sync/errgroup" @@ -1514,19 +1513,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { ) } - tmpl, tmplErr := api.Database.GetTemplateByID(ctx, newWorkspace.TemplateID) - if tmplErr != nil { - api.Logger.Warn( - ctx, - "failed to fetch the template of the workspace marked as dormant", - slog.Error(err), - slog.F("workspace_id", newWorkspace.ID), - slog.F("template_id", newWorkspace.TemplateID), - ) - } - - if initiatorErr == nil && tmplErr == nil { - dormantTime := dbtime.Time(now).Add(time.Duration(tmpl.TimeTilDormant)) + if initiatorErr == nil { _, err = api.NotificationsEnqueuer.Enqueue( // nolint:gocritic // Need notifier actor to enqueue notifications dbauthz.AsNotifier(ctx), @@ -1535,7 +1522,7 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { map[string]string{ "name": newWorkspace.Name, "reason": "a " + initiator.Username + " request", - "timeTilDormant": humanize.Time(dormantTime), + "timeTilDormant": notifications.DormantDeletionText(newWorkspace.DeletingAt), }, "api", newWorkspace.ID, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index de7ae93ccf6fa..b2fdbd705081a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -5156,6 +5156,68 @@ func TestWorkspaceNotifications(t *testing.T) { require.Contains(t, sent[0].Targets, workspace.ID) require.Contains(t, sent[0].Targets, workspace.OrganizationID) require.Contains(t, sent[0].Targets, workspace.OwnerID) + require.Equal(t, "line with your template's auto-deletion policy", sent[0].Labels["timeTilDormant"]) + }) + + t.Run("InitiatorNotOwnerWithAutoDelete", func(t *testing.T) { + t.Parallel() + + // Given + var ( + notifyEnq = ¬ificationstest.FakeEnqueuer{} + // 35 days keeps humanize.Time deterministically in its "1 month" bucket. + timeTilDormantAutoDelete = 35 * 24 * time.Hour + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: notifyEnq, + // The AGPL store ignores TimeTilDormantAutoDelete, so the mock + // writes it to the template row for deleting_at computation. + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + template.TimeTilDormantAutoDelete = int64(options.TimeTilDormantAutoDelete) + return schedule.NewAGPLTemplateScheduleStore().Set(ctx, db, template, options) + }, + GetFn: func(_ context.Context, _ database.Store, _ uuid.UUID) (schedule.TemplateScheduleOptions, error) { + return schedule.TemplateScheduleOptions{ + UserAutostartEnabled: false, + UserAutostopEnabled: true, + DefaultTTL: 0, + AutostopRequirement: schedule.TemplateAutostopRequirement{}, + TimeTilDormantAutoDelete: timeTilDormantAutoDelete, + }, nil + }, + }, + }) + user = coderdtest.CreateFirstUser(t, client) + memberClient, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID, rbac.RoleOwner()) + version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template = coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](timeTilDormantAutoDelete.Milliseconds()) + }) + workspace = coderdtest.CreateWorkspace(t, client, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + ) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + + // When + err := memberClient.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ + Dormant: true, + }) + + // Then + require.NoError(t, err, "mark workspace as dormant") + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceDormant)) + require.Len(t, sent, 1) + require.Contains(t, sent[0].Labels, "timeTilDormant") + require.Contains(t, sent[0].Labels["timeTilDormant"], "1 month", + "timeTilDormant must humanize the workspace's deleting_at, got %q", + sent[0].Labels["timeTilDormant"]) + require.NotContains(t, sent[0].Labels["timeTilDormant"], "ago", + "timeTilDormant must be a future timestamp, got %q", + sent[0].Labels["timeTilDormant"]) }) t.Run("InitiatorIsOwner", func(t *testing.T) {