diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 8f742a43f5b77..93da5d8df28ba 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -382,8 +382,12 @@ func (e *Executor) runOnce(t time.Time) Stats { Old: wsOld.WorkspaceTable(), New: wsNew, } - // To keep the `ws` accurate without doing a sql fetch + // To keep the `ws` accurate without doing a sql fetch. + // deleting_at is computed atomically inside the UPDATE from + // the workspace's template_id, so it reflects the auto-delete + // deadline the database persisted. ws.DormantAt = wsNew.DormantAt + ws.DeletingAt = wsNew.DeletingAt shouldNotifyDormancy = true @@ -484,16 +488,22 @@ func (e *Executor) runOnce(t time.Time) Stats { } } if shouldNotifyDormancy { - dormantTime := dbtime.Now().Add(time.Duration(tmpl.TimeTilDormant)) + labels := map[string]string{ + "name": ws.Name, + "reason": "inactivity exceeded the dormancy threshold", + } + // DeletingAt is set by the UPDATE only when the template's + // time_til_dormant_autodelete is non-zero, so skip the label when + // auto-delete is disabled so the body omits the deletion + // timeline. + if ws.DeletingAt.Valid { + labels["timeTilDelete"] = humanize.Time(ws.DeletingAt.Time) + } _, err = e.notificationsEnqueuer.Enqueue( e.ctx, ws.OwnerID, notifications.TemplateWorkspaceDormant, - map[string]string{ - "name": ws.Name, - "reason": "inactivity exceeded the dormancy threshold", - "timeTilDormant": humanize.Time(dormantTime), - }, + labels, "lifecycle_executor", ws.ID, ws.OwnerID, diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 607e889444ac1..c9caf339be668 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -1437,6 +1437,94 @@ 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) + + // The template does not configure auto-delete, so the body must not + // indicate a deletion timeline. + require.NotContains(t, sent[0].Labels, "timeTilDelete") + 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() + + // Setup template with dormancy and auto-delete and create a workspace + // with it. The two durations are intentionally far apart to reliably + // check what's rendered in the notification. + var ( + ticker = make(chan time.Time) + statCh = make(chan autobuild.Stats) + notifyEnq = notificationstest.FakeEnqueuer{} + // 35 days is inside humanize.Time's "1 month" bucket (between 30 and 60 days). + 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) + + // Stop workspace + 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) + + // Wait for workspace to become dormant + 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) + + // Check that the workspace is dormant + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.NotNil(t, workspace.DormantAt) + + // The notification body should render the deletion countdown using the template's + // `time_til_dormant_autodelete` value. With auto-delete at 35 days and dormancy + // at 1 minute, humanize.Time renders the label as "1 month from now". + sent := notifyEnq.Sent() + require.Len(t, sent, 1) + require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceDormant) + require.Contains(t, sent[0].Labels, "timeTilDelete") + require.Contains(t, sent[0].Labels["timeTilDelete"], "1 month", + "timeTilDelete must humanize TimeTilDormantAutoDelete, got %q", + sent[0].Labels["timeTilDelete"]) + require.NotContains(t, sent[0].Labels["timeTilDelete"], "ago", + "timeTilDelete must be a future timestamp, got %q", + sent[0].Labels["timeTilDelete"]) }) } diff --git a/coderd/database/migrations/000526_dormancy_notification_use_til_delete.down.sql b/coderd/database/migrations/000526_dormancy_notification_use_til_delete.down.sql new file mode 100644 index 0000000000000..6ded69b781a85 --- /dev/null +++ b/coderd/database/migrations/000526_dormancy_notification_use_til_delete.down.sql @@ -0,0 +1,5 @@ +-- Revert to the body left by migration 000518 +-- (000311's wording with the corrected docs URL). +UPDATE notification_templates SET body_template = E'Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due to inactivity exceeding the dormancy threshold.\n\n' || + E'This workspace will be automatically deleted in {{.Labels.timeTilDormant}} if it remains inactive.\n\n' || + E'To prevent deletion, activate your workspace using the link below.' WHERE id = '0ea69165-ec14-4314-91f1-69566ac3c5a0'; diff --git a/coderd/database/migrations/000526_dormancy_notification_use_til_delete.up.sql b/coderd/database/migrations/000526_dormancy_notification_use_til_delete.up.sql new file mode 100644 index 0000000000000..04110ed4817de --- /dev/null +++ b/coderd/database/migrations/000526_dormancy_notification_use_til_delete.up.sql @@ -0,0 +1,11 @@ +-- Update the dormant workspace notification body so that the deletion +-- countdown references a dedicated `timeTilDelete` label, and to only +-- include the deletion time if the templates' `time_til_dormant_autodelete` +-- is enabled. +UPDATE notification_templates SET body_template = E'Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due to inactivity exceeding the dormancy threshold.\n\n' || + E'{{ if .Labels.timeTilDelete -}}\n' || + E'This workspace will be automatically deleted in {{.Labels.timeTilDelete}} if it remains inactive.\n\n' || + E'To prevent deletion, activate your workspace using the link below.\n' || + E'{{- else -}}\n' || + E'Activate your workspace using the link below to resume working in it.\n' || + E'{{- end }}' WHERE id = '0ea69165-ec14-4314-91f1-69566ac3c5a0'; diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index a59e64be42ff7..036dd5c096b0c 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -788,11 +788,29 @@ func TestNotificationTemplates_Golden(t *testing.T) { UserEmail: "bobby@coder.com", UserUsername: "bobby", Labels: map[string]string{ - "name": "bobby-workspace", - "reason": "breached the template's threshold for inactivity", - "initiator": "autobuild", - "dormancyHours": "24", - "timeTilDormant": "24 hours", + "name": "bobby-workspace", + "reason": "breached the template's threshold for inactivity", + "initiator": "autobuild", + "dormancyHours": "24", + "timeTilDelete": "24 hours", + }, + }, + }, + { + // TemplateWorkspaceDormant body should not promise auto-deletion + // when the template has no `time_til_dormant_autodelete` set, in + // which case the enqueue sites leave `timeTilDelete` unset. + name: "TemplateWorkspaceDormant_NoAutoDelete", + id: notifications.TemplateWorkspaceDormant, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "name": "bobby-workspace", + "reason": "breached the template's threshold for inactivity", + "initiator": "autobuild", + "dormancyHours": "24", }, }, }, diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden new file mode 100644 index 0000000000000..e41eeb19fee03 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceDormant_NoAutoDelete.html.golden @@ -0,0 +1,86 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Workspace "bobby-workspace" marked as dormant +Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48 +Date: Fri, 11 Oct 2024 09:03:06 +0000 +Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +MIME-Version: 1.0 + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/plain; charset=UTF-8 + +Hi Bobby, + +Your workspace bobby-workspace has been marked as dormant (https://coder.co= +m/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due = +to inactivity exceeding the dormancy threshold. + +Activate your workspace using the link below to resume working in it. + + +View workspace: http://test.com/@bobby/bobby-workspace + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Workspace "bobby-workspace" marked as dormant + + +
+
+ 3D"Cod= +
+

+ Workspace "bobby-workspace" marked as dormant +

+
+

Hi Bobby,

+

Your workspace bobby-workspace has been marked = +as dormant due to inactivity ex= +ceeding the dormancy threshold.

+ +

Activate your workspace using the link below to resume working in it. +

+
+ =20 + + View workspace + + =20 +
+
+

© 2024 Coder. All rights reserved - h= +ttp://test.com

+

Click here to manage your notification = +settings

+

Stop receiving emails like this

+
+
+ + + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4-- diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDormant.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDormant.json.golden index 97bdaaf0c03d4..a97d9afe4593f 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDormant.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDormant.json.golden @@ -20,7 +20,7 @@ "initiator": "autobuild", "name": "bobby-workspace", "reason": "breached the template's threshold for inactivity", - "timeTilDormant": "24 hours" + "timeTilDelete": "24 hours" }, "data": null, "targets": null diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDormant_NoAutoDelete.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDormant_NoAutoDelete.json.golden new file mode 100644 index 0000000000000..d800a7961b6b2 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceDormant_NoAutoDelete.json.golden @@ -0,0 +1,31 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.2", + "notification_name": "Workspace Marked as Dormant", + "notification_template_id": "00000000-0000-0000-0000-000000000000", + "user_id": "00000000-0000-0000-0000-000000000000", + "user_email": "bobby@coder.com", + "user_name": "Bobby", + "user_username": "bobby", + "actions": [ + { + "label": "View workspace", + "url": "http://test.com/@bobby/bobby-workspace" + } + ], + "labels": { + "dormancyHours": "24", + "initiator": "autobuild", + "name": "bobby-workspace", + "reason": "breached the template's threshold for inactivity" + }, + "data": null, + "targets": null + }, + "title": "Workspace \"bobby-workspace\" marked as dormant", + "title_markdown": "Workspace \"bobby-workspace\" marked as dormant", + "body": "Your workspace bobby-workspace has been marked as dormant (https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due to inactivity exceeding the dormancy threshold.\n\nActivate your workspace using the link below to resume working in it.", + "body_markdown": "Your workspace **bobby-workspace** has been marked as [**dormant**](https://coder.com/docs/admin/templates/managing-templates/schedule#dormancy-threshold) due to inactivity exceeding the dormancy threshold.\n\nActivate your workspace using the link below to resume working in it." +} \ No newline at end of file diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 62cc5e6f5336e..0494521b9f7b3 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1448,29 +1448,24 @@ 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 { + labels := map[string]string{ + "name": newWorkspace.Name, + "reason": "a " + initiator.Username + " request", + } + // DeletingAt is set by the UPDATE only when the template's + // time_til_dormant_autodelete is non-zero, so skip the label when + // auto-delete is disabled so the body omits the deletion + // timeline. + if newWorkspace.DeletingAt.Valid { + labels["timeTilDelete"] = humanize.Time(newWorkspace.DeletingAt.Time) + } _, err = api.NotificationsEnqueuer.Enqueue( // nolint:gocritic // Need notifier actor to enqueue notifications dbauthz.AsNotifier(ctx), newWorkspace.OwnerID, notifications.TemplateWorkspaceDormant, - map[string]string{ - "name": newWorkspace.Name, - "reason": "a " + initiator.Username + " request", - "timeTilDormant": humanize.Time(dormantTime), - }, + labels, "api", newWorkspace.ID, newWorkspace.OwnerID, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 09ad56ca66d1e..073dcba078beb 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4876,6 +4876,75 @@ 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) + // Auto-delete is not configured on this template, so the body must + // omit the deletion timeline. + require.NotContains(t, sent[0].Labels, "timeTilDelete") + }) + + t.Run("InitiatorNotOwnerWithAutoDelete", func(t *testing.T) { + t.Parallel() + + // Given + var ( + notifyEnq = ¬ificationstest.FakeEnqueuer{} + // 35 days sits solidly inside humanize.Time's "1 month" + // bucket (between 30 and 60 days), so the rendered label is + // deterministic regardless of microsecond-level timing + // differences. + timeTilDormantAutoDelete = 35 * 24 * time.Hour + client = coderdtest.New(t, &coderdtest.Options{ + IncludeProvisionerDaemon: true, + NotificationsEnqueuer: notifyEnq, + // AGPL templateScheduleStore drops TimeTilDormantAutoDelete + // when Set runs. The mock propagates it into the template + // row so the UPDATE in UpdateWorkspaceDormantDeletingAt + // can compute deleting_at. + 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, "timeTilDelete") + require.Contains(t, sent[0].Labels["timeTilDelete"], "1 month", + "timeTilDelete must humanize the workspace's deleting_at, got %q", + sent[0].Labels["timeTilDelete"]) + require.NotContains(t, sent[0].Labels["timeTilDelete"], "ago", + "timeTilDelete must be a future timestamp, got %q", + sent[0].Labels["timeTilDelete"]) }) t.Run("InitiatorIsOwner", func(t *testing.T) {