diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 082ee0feedfcf..10692f91ff1c8 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -19,7 +19,6 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" "github.com/coder/coder/v2/coderd/database/pubsub" - "github.com/coder/coder/v2/coderd/dormancy" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/wsbuilder" @@ -145,10 +144,11 @@ func (e *Executor) runOnce(t time.Time) Stats { var ( job *database.ProvisionerJob auditLog *auditParams - dormantNotification *dormancy.WorkspaceDormantNotification + shouldNotifyDormancy bool nextBuild *database.WorkspaceBuild activeTemplateVersion database.TemplateVersion ws database.Workspace + tmpl database.Template didAutoUpdate bool ) err := e.db.InTx(func(tx database.Store) error { @@ -182,17 +182,17 @@ func (e *Executor) runOnce(t time.Time) Stats { return xerrors.Errorf("get template scheduling options: %w", err) } - template, err := tx.GetTemplateByID(e.ctx, ws.TemplateID) + tmpl, err = tx.GetTemplateByID(e.ctx, ws.TemplateID) if err != nil { return xerrors.Errorf("get template by ID: %w", err) } - activeTemplateVersion, err = tx.GetTemplateVersionByID(e.ctx, template.ActiveVersionID) + activeTemplateVersion, err = tx.GetTemplateVersionByID(e.ctx, tmpl.ActiveVersionID) if err != nil { return xerrors.Errorf("get active template version by ID: %w", err) } - accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(template) + accessControl := (*(e.accessControlStore.Load())).GetTemplateAccessControl(tmpl) nextTransition, reason, err := getNextTransition(user, ws, latestBuild, latestJob, templateSchedule, currentTick) if err != nil { @@ -215,7 +215,7 @@ func (e *Executor) runOnce(t time.Time) Stats { log.Debug(e.ctx, "autostarting with active version") builder = builder.ActiveVersion() - if latestBuild.TemplateVersionID != template.ActiveVersionID { + if latestBuild.TemplateVersionID != tmpl.ActiveVersionID { // control flag to know if the workspace was auto-updated, // so the lifecycle executor can notify the user didAutoUpdate = true @@ -248,12 +248,7 @@ func (e *Executor) runOnce(t time.Time) Stats { return xerrors.Errorf("update workspace dormant deleting at: %w", err) } - dormantNotification = &dormancy.WorkspaceDormantNotification{ - Workspace: ws, - Initiator: "autobuild", - Reason: "breached the template's threshold for inactivity", - CreatedBy: "lifecycleexecutor", - } + shouldNotifyDormancy = true log.Info(e.ctx, "dormant workspace", slog.F("last_used_at", ws.LastUsedAt), @@ -325,14 +320,24 @@ func (e *Executor) runOnce(t time.Time) Stats { return xerrors.Errorf("post provisioner job to pubsub: %w", err) } } - if dormantNotification != nil { - _, err = dormancy.NotifyWorkspaceDormant( + if shouldNotifyDormancy { + _, err = e.notificationsEnqueuer.Enqueue( e.ctx, - e.notificationsEnqueuer, - *dormantNotification, + ws.OwnerID, + notifications.TemplateWorkspaceDormant, + map[string]string{ + "name": ws.Name, + "reason": "inactivity exceeded the dormancy threshold", + "timeTilDormant": time.Duration(tmpl.TimeTilDormant).String(), + }, + "lifecycle_executor", + ws.ID, + ws.OwnerID, + ws.TemplateID, + ws.OrganizationID, ) if err != nil { - log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", dormantNotification.Workspace.ID)) + log.Warn(e.ctx, "failed to notify of workspace marked as dormant", slog.Error(err), slog.F("workspace_id", ws.ID)) } } return nil diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 243b2550ccf63..f2fb37c8b471c 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -1122,7 +1122,6 @@ func TestNotifications(t *testing.T) { require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID) require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID) require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID) - require.Equal(t, notifyEnq.Sent[0].Labels["initiator"], "autobuild") }) } diff --git a/coderd/database/migrations/000232_update_dormancy_notification_template.down.sql b/coderd/database/migrations/000232_update_dormancy_notification_template.down.sql new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/coderd/database/migrations/000232_update_dormancy_notification_template.up.sql b/coderd/database/migrations/000232_update_dormancy_notification_template.up.sql new file mode 100644 index 0000000000000..c36502841d86e --- /dev/null +++ b/coderd/database/migrations/000232_update_dormancy_notification_template.up.sql @@ -0,0 +1,16 @@ +UPDATE notification_templates +SET + body_template = E'Hi {{.UserName}}\n\n' || + E'Your workspace **{{.Labels.name}}** has been marked as [**dormant**](https://coder.com/docs/templates/schedule#dormancy-threshold-enterprise) because of {{.Labels.reason}}.\n' || + E'Dormant workspaces are [automatically deleted](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) after {{.Labels.timeTilDormant}} of inactivity.\n' || + E'To prevent deletion, use your workspace with the link below.' +WHERE + id = '0ea69165-ec14-4314-91f1-69566ac3c5a0'; + +UPDATE notification_templates +SET + body_template = E'Hi {{.UserName}}\n\n' || + E'Your workspace **{{.Labels.name}}** has been marked for **deletion** after {{.Labels.timeTilDormant}} of [dormancy](https://coder.com/docs/templates/schedule#dormancy-auto-deletion-enterprise) because of {{.Labels.reason}}.\n' || + E'To prevent deletion, use your workspace with the link below.' +WHERE + id = '51ce2fdf-c9ca-4be1-8d70-628674f9bc42'; diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index 2fd372e9df029..c0a2f25323957 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -132,4 +132,3 @@ WHERE id IN -- name: GetNotificationMessagesByStatus :many SELECT * FROM notification_messages WHERE status = @status LIMIT sqlc.arg('limit')::int; - diff --git a/coderd/dormancy/notifications.go b/coderd/dormancy/notifications.go deleted file mode 100644 index 162ca272db635..0000000000000 --- a/coderd/dormancy/notifications.go +++ /dev/null @@ -1,75 +0,0 @@ -// This package is located outside of the enterprise package to ensure -// accessibility in the putWorkspaceDormant function. This design choice allows -// workspaces to be taken out of dormancy even if the license has expired, -// ensuring critical functionality remains available without an active -// enterprise license. -package dormancy - -import ( - "context" - - "github.com/google/uuid" - - "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/notifications" -) - -type WorkspaceDormantNotification struct { - Workspace database.Workspace - Initiator string - Reason string - CreatedBy string -} - -func NotifyWorkspaceDormant( - ctx context.Context, - enqueuer notifications.Enqueuer, - notification WorkspaceDormantNotification, -) (id *uuid.UUID, err error) { - labels := map[string]string{ - "name": notification.Workspace.Name, - "initiator": notification.Initiator, - "reason": notification.Reason, - } - return enqueuer.Enqueue( - ctx, - notification.Workspace.OwnerID, - notifications.TemplateWorkspaceDormant, - labels, - notification.CreatedBy, - // Associate this notification with all the related entities. - notification.Workspace.ID, - notification.Workspace.OwnerID, - notification.Workspace.TemplateID, - notification.Workspace.OrganizationID, - ) -} - -type WorkspaceMarkedForDeletionNotification struct { - Workspace database.Workspace - Reason string - CreatedBy string -} - -func NotifyWorkspaceMarkedForDeletion( - ctx context.Context, - enqueuer notifications.Enqueuer, - notification WorkspaceMarkedForDeletionNotification, -) (id *uuid.UUID, err error) { - labels := map[string]string{ - "name": notification.Workspace.Name, - "reason": notification.Reason, - } - return enqueuer.Enqueue( - ctx, - notification.Workspace.OwnerID, - notifications.TemplateWorkspaceMarkedForDeletion, - labels, - notification.CreatedBy, - // Associate this notification with all the related entities. - notification.Workspace.ID, - notification.Workspace.OwnerID, - notification.Workspace.TemplateID, - notification.Workspace.OrganizationID, - ) -} diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 7d55ac01c5b52..37fe4a2ce5ce3 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -29,6 +29,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/dispatch" + "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/coderd/util/syncmap" "github.com/coder/coder/v2/codersdk" @@ -603,6 +604,107 @@ func TestNotifierPaused(t *testing.T) { }, testutil.WaitShort, testutil.IntervalFast) } +func TestNotifcationTemplatesBody(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres; it relies on the notification templates added by migrations in the database") + } + + tests := []struct { + name string + id uuid.UUID + payload types.MessagePayload + }{ + { + name: "TemplateWorkspaceDeleted", + id: notifications.TemplateWorkspaceDeleted, + payload: types.MessagePayload{ + UserName: "bobby", + Labels: map[string]string{ + "name": "bobby-workspace", + "reason": "autodeleted due to dormancy", + "initiator": "autobuild", + }, + }, + }, + { + name: "TemplateWorkspaceAutobuildFailed", + id: notifications.TemplateWorkspaceAutobuildFailed, + payload: types.MessagePayload{ + UserName: "bobby", + Labels: map[string]string{ + "name": "bobby-workspace", + "reason": "autostart", + }, + }, + }, + { + name: "TemplateWorkspaceDormant", + id: notifications.TemplateWorkspaceDormant, + payload: types.MessagePayload{ + UserName: "bobby", + Labels: map[string]string{ + "name": "bobby-workspace", + "reason": "breached the template's threshold for inactivity", + "initiator": "autobuild", + "dormancyHours": "24", + }, + }, + }, + { + name: "TemplateWorkspaceAutoUpdated", + id: notifications.TemplateWorkspaceAutoUpdated, + payload: types.MessagePayload{ + UserName: "bobby", + Labels: map[string]string{ + "name": "bobby-workspace", + "template_version_name": "1.0", + }, + }, + }, + { + name: "TemplateWorkspaceMarkedForDeletion", + id: notifications.TemplateWorkspaceMarkedForDeletion, + payload: types.MessagePayload{ + UserName: "bobby", + Labels: map[string]string{ + "name": "bobby-workspace", + "reason": "template updated to new dormancy policy", + "dormancyHours": "24", + }, + }, + }, + } + + for _, tc := range tests { + tc := tc + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + _, _, sql := dbtestutil.NewDBWithSQLDB(t) + + var ( + titleTmpl string + bodyTmpl string + ) + err := sql. + QueryRow("SELECT title_template, body_template FROM notification_templates WHERE id = $1 LIMIT 1", tc.id). + Scan(&titleTmpl, &bodyTmpl) + require.NoError(t, err, "failed to query body template for template:", tc.id) + + title, err := render.GoTemplate(titleTmpl, tc.payload, nil) + require.NoError(t, err, "failed to render notification title template") + require.NotEmpty(t, title, "title should not be empty") + + body, err := render.GoTemplate(bodyTmpl, tc.payload, nil) + require.NoError(t, err, "failed to render notification body template") + require.NotEmpty(t, body, "body should not be empty") + }) + } +} + type fakeHandler struct { mu sync.RWMutex succeeded, failed []string diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index b71935e9a0436..458f79ca348e6 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1101,13 +1101,11 @@ func (s *server) notifyWorkspaceBuildFailed(ctx context.Context, workspace datab return // failed workspace build initiated by a user should not notify } reason = string(build.Reason) - initiator := "autobuild" if _, err := s.NotificationsEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceAutobuildFailed, map[string]string{ - "name": workspace.Name, - "initiator": initiator, - "reason": reason, + "name": workspace.Name, + "reason": reason, }, "provisionerdserver", // Associate this notification with all the related entities. workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 2117d8e5f3df8..79c1b00ac78ee 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -1797,7 +1797,6 @@ func TestNotifications(t *testing.T) { require.Contains(t, notifEnq.Sent[0].Targets, workspace.ID) require.Contains(t, notifEnq.Sent[0].Targets, workspace.OrganizationID) require.Contains(t, notifEnq.Sent[0].Targets, user.ID) - require.Equal(t, "autobuild", notifEnq.Sent[0].Labels["initiator"]) require.Equal(t, string(tc.buildReason), notifEnq.Sent[0].Labels["reason"]) } else { require.Len(t, notifEnq.Sent, 0) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 1f4c4f276a5b8..ceba543639cc3 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -23,9 +23,9 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/provisionerjobs" - "github.com/coder/coder/v2/coderd/dormancy" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/schedule/cron" @@ -953,25 +953,43 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { // We don't need to notify the owner if they are the one making the request. if req.Dormant && apiKey.UserID != workspace.OwnerID { - initiator, err := api.Database.GetUserByID(ctx, apiKey.UserID) - if err != nil { + initiator, initiatorErr := api.Database.GetUserByID(ctx, apiKey.UserID) + if initiatorErr != nil { api.Logger.Warn( ctx, - "failed to fetch the user that marked the workspace", + "failed to fetch the user that marked the workspace as dormant", slog.Error(err), slog.F("workspace_id", workspace.ID), slog.F("user_id", apiKey.UserID), ) - } else { - _, err = dormancy.NotifyWorkspaceDormant( + } + + tmpl, tmplErr := api.Database.GetTemplateByID(ctx, workspace.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", workspace.ID), + slog.F("template_id", workspace.TemplateID), + ) + } + + if initiatorErr == nil && tmplErr == nil { + _, err = api.NotificationsEnqueuer.Enqueue( ctx, - api.NotificationsEnqueuer, - dormancy.WorkspaceDormantNotification{ - Workspace: workspace, - Initiator: initiator.Username, - Reason: "requested by user", - CreatedBy: "api", + workspace.OwnerID, + notifications.TemplateWorkspaceDormant, + map[string]string{ + "name": workspace.Name, + "reason": "a " + initiator.Username + " request", + "timeTilDormant": time.Duration(tmpl.TimeTilDormant).String(), }, + "api", + workspace.ID, + workspace.OwnerID, + workspace.TemplateID, + workspace.OrganizationID, ) if err != nil { api.Logger.Warn(ctx, "failed to notify of workspace marked as dormant", slog.Error(err)) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index c2a8c3c9c4ec8..94e89bcd50f98 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -3457,13 +3457,13 @@ func TestNotifications(t *testing.T) { IncludeProvisionerDaemon: true, NotificationsEnqueuer: notifyEnq, }) - user = coderdtest.CreateFirstUser(t, client) - memberClient, member = 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) - workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) - _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + 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) + workspace = coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + _ = coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) ) ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -3483,7 +3483,6 @@ func TestNotifications(t *testing.T) { require.Contains(t, notifyEnq.Sent[0].Targets, workspace.ID) require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OrganizationID) require.Contains(t, notifyEnq.Sent[0].Targets, workspace.OwnerID) - require.Equal(t, notifyEnq.Sent[0].Labels["initiator"], member.Username) }) t.Run("InitiatorIsOwner", func(t *testing.T) { diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index c3cb5001e091c..c38b8f509b5c3 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -16,7 +16,6 @@ import ( "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/dormancy" "github.com/coder/coder/v2/coderd/notifications" agpl "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/tracing" @@ -205,18 +204,25 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S return database.Template{}, err } - for _, workspace := range markedForDeletion { - _, err = dormancy.NotifyWorkspaceMarkedForDeletion( + for _, ws := range markedForDeletion { + _, err = s.enqueuer.Enqueue( ctx, - s.enqueuer, - dormancy.WorkspaceMarkedForDeletionNotification{ - Workspace: workspace, - Reason: "template updated to new dormancy policy", - CreatedBy: "scheduletemplate", + ws.OwnerID, + notifications.TemplateWorkspaceMarkedForDeletion, + map[string]string{ + "name": ws.Name, + "reason": "an update to the template's dormancy", + "timeTilDormant": opts.TimeTilDormantAutoDelete.String(), }, + "scheduletemplate", + // Associate this notification with all the related entities. + ws.ID, + ws.OwnerID, + ws.TemplateID, + ws.OrganizationID, ) if err != nil { - s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", workspace.ID)) + s.logger.Warn(ctx, "failed to notify of workspace marked for deletion", slog.Error(err), slog.F("workspace_id", ws.ID)) } }