diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 62bb8b2fd2..c700773028 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -274,7 +274,7 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { } if tc.expectNotification { - sent := enqueuer.Sent() + sent := enqueuer.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutoUpdated)) require.Len(t, sent, 1) require.Equal(t, sent[0].UserID, workspace.OwnerID) require.Contains(t, sent[0].Targets, workspace.TemplateID) @@ -285,7 +285,8 @@ func TestExecutorAutostartTemplateUpdated(t *testing.T) { require.Equal(t, "autobuild", sent[0].Labels["initiator"]) require.Equal(t, "autostart", sent[0].Labels["reason"]) } else { - require.Empty(t, enqueuer.Sent()) + sent := enqueuer.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceAutoUpdated)) + require.Empty(t, sent) } }) } diff --git a/coderd/database/migrations/000279_workspace_create_notification.down.sql b/coderd/database/migrations/000279_workspace_create_notification.down.sql new file mode 100644 index 0000000000..7780ca4663 --- /dev/null +++ b/coderd/database/migrations/000279_workspace_create_notification.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff'; diff --git a/coderd/database/migrations/000279_workspace_create_notification.up.sql b/coderd/database/migrations/000279_workspace_create_notification.up.sql new file mode 100644 index 0000000000..ca8678d4bc --- /dev/null +++ b/coderd/database/migrations/000279_workspace_create_notification.up.sql @@ -0,0 +1,16 @@ +INSERT INTO notification_templates + (id, name, title_template, body_template, "group", actions) +VALUES ( + '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff', + 'Workspace Created', + E'Workspace ''{{.Labels.workspace}}'' has been created', + E'Hello {{.UserName}},\n\n'|| + E'The workspace **{{.Labels.workspace}}** has been created from the template **{{.Labels.template}}** using version **{{.Labels.version}}**.', + 'Workspace Events', + '[ + { + "label": "See workspace", + "url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}" + } + ]'::jsonb +); diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index e33a85b523..12aecbaac7 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -7,6 +7,7 @@ import "github.com/google/uuid" // Workspace-related events. var ( + TemplateWorkspaceCreated = uuid.MustParse("281fdf73-c6d6-4cbb-8ff5-888baf8a2fff") TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed") TemplateWorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9") TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0") diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 22b8c654e6..90cf1a46be 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1034,6 +1034,20 @@ func TestNotificationTemplates_Golden(t *testing.T) { }, }, }, + { + name: "TemplateWorkspaceCreated", + id: notifications.TemplateWorkspaceCreated, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "workspace": "bobby-workspace", + "template": "bobby-template", + "version": "alpha", + }, + }, + }, } // We must have a test case for every notification_template. This is enforced below: diff --git a/coderd/notifications/notificationstest/fake_enqueuer.go b/coderd/notifications/notificationstest/fake_enqueuer.go index 0231377209..b26501cf49 100644 --- a/coderd/notifications/notificationstest/fake_enqueuer.go +++ b/coderd/notifications/notificationstest/fake_enqueuer.go @@ -92,8 +92,31 @@ func (f *FakeEnqueuer) Clear() { f.sent = nil } -func (f *FakeEnqueuer) Sent() []*FakeNotification { +func (f *FakeEnqueuer) Sent(matchers ...func(*FakeNotification) bool) []*FakeNotification { f.mu.Lock() defer f.mu.Unlock() - return append([]*FakeNotification{}, f.sent...) + + sent := []*FakeNotification{} + for _, notif := range f.sent { + // Check this notification matches all given matchers + matches := true + for _, matcher := range matchers { + if !matcher(notif) { + matches = false + break + } + } + + if matches { + sent = append(sent, notif) + } + } + + return sent +} + +func WithTemplateID(id uuid.UUID) func(*FakeNotification) bool { + return func(n *FakeNotification) bool { + return n.TemplateID == id + } } diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden new file mode 100644 index 0000000000..000b2a71ac --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden @@ -0,0 +1,80 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Workspace 'bobby-workspace' has been created +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 + +Hello Bobby, + +The workspace bobby-workspace has been created from the template bobby-temp= +late using version alpha. + + +See workspace: http://test.com/@bobby/bobby-workspace + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Workspace 'bobby-workspace' has been created + + +
+
+ 3D"Cod= +
+

+ Workspace 'bobby-workspace' has been created +

+
+

Hello Bobby,

+ +

The workspace bobby-workspace has been created from the= + template bobby-template using version alpha.

+
+
+ =20 + + See 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/TemplateWorkspaceCreated.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden new file mode 100644 index 0000000000..46354c4ffe --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden @@ -0,0 +1,29 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.1", + "notification_name": "Workspace Created", + "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": "See workspace", + "url": "http://test.com/@bobby/bobby-workspace" + } + ], + "labels": { + "template": "bobby-template", + "version": "alpha", + "workspace": "bobby-workspace" + }, + "data": null + }, + "title": "Workspace 'bobby-workspace' has been created", + "title_markdown": "Workspace 'bobby-workspace' has been created", + "body": "Hello Bobby,\n\nThe workspace bobby-workspace has been created from the template bobby-template using version alpha.", + "body_markdown": "Hello Bobby,\n\nThe workspace **bobby-workspace** has been created from the template **bobby-template** using version **alpha**." +} \ No newline at end of file diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 0234c31888..19fb1ec1ce 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -666,6 +666,8 @@ func createWorkspace( return err }, nil) + api.notifyWorkspaceCreated(ctx, workspace, req.RichParameterValues) + var bldErr wsbuilder.BuildError if xerrors.As(err, &bldErr) { httpapi.Write(ctx, rw, bldErr.Status, codersdk.Response{ @@ -735,6 +737,64 @@ func createWorkspace( httpapi.Write(ctx, rw, http.StatusCreated, w) } +func (api *API) notifyWorkspaceCreated( + ctx context.Context, + workspace database.Workspace, + parameters []codersdk.WorkspaceBuildParameter, +) { + log := api.Logger.With(slog.F("workspace_id", workspace.ID)) + + template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID) + if err != nil { + log.Warn(ctx, "failed to fetch template for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err)) + return + } + + owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID) + if err != nil { + log.Warn(ctx, "failed to fetch user for workspace creation notification", slog.F("owner_id", workspace.OwnerID), slog.Error(err)) + return + } + + version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID) + if err != nil { + log.Warn(ctx, "failed to fetch template version for workspace creation notification", slog.F("template_version_id", template.ActiveVersionID), slog.Error(err)) + return + } + + buildParameters := make([]map[string]any, len(parameters)) + for idx, parameter := range parameters { + buildParameters[idx] = map[string]any{ + "name": parameter.Name, + "value": parameter.Value, + } + } + + if _, err := api.NotificationsEnqueuer.EnqueueWithData( + // nolint:gocritic // Need notifier actor to enqueue notifications + dbauthz.AsNotifier(ctx), + workspace.OwnerID, + notifications.TemplateWorkspaceCreated, + map[string]string{ + "workspace": workspace.Name, + "template": template.Name, + "version": version.Name, + }, + map[string]any{ + "workspace": map[string]any{"id": workspace.ID, "name": workspace.Name}, + "template": map[string]any{"id": template.ID, "name": template.Name}, + "template_version": map[string]any{"id": version.ID, "name": version.Name}, + "owner": map[string]any{"id": owner.ID, "name": owner.Name}, + "parameters": buildParameters, + }, + "api-workspaces-create", + // Associate this notification with all the related entities + workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID, + ); err != nil { + log.Warn(ctx, "failed to notify of workspace creation", slog.Error(err)) + } +} + // @Summary Update workspace metadata by ID // @ID update-workspace-metadata-by-id // @Security CoderSessionToken diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 6a2856dcbb..d6e365011b 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -571,6 +571,59 @@ func TestPostWorkspacesByOrganization(t *testing.T) { require.Equal(t, http.StatusConflict, apiErr.StatusCode()) }) + t.Run("CreateSendsNotification", func(t *testing.T) { + t.Parallel() + + enqueuer := notificationstest.FakeEnqueuer{} + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, NotificationsEnqueuer: &enqueuer}) + user := coderdtest.CreateFirstUser(t, client) + memberClient, memberUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + workspace := coderdtest.CreateWorkspace(t, memberClient, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, memberClient, workspace.LatestBuild.ID) + + sent := enqueuer.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceCreated)) + require.Len(t, sent, 1) + require.Equal(t, memberUser.ID, sent[0].UserID) + require.Contains(t, sent[0].Targets, template.ID) + require.Contains(t, sent[0].Targets, workspace.ID) + require.Contains(t, sent[0].Targets, workspace.OrganizationID) + require.Contains(t, sent[0].Targets, workspace.OwnerID) + }) + + t.Run("CreateSendsNotificationToCorrectUser", func(t *testing.T) { + t.Parallel() + + enqueuer := notificationstest.FakeEnqueuer{} + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, NotificationsEnqueuer: &enqueuer}) + user := coderdtest.CreateFirstUser(t, client) + _, memberUser := coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + + ctx := testutil.Context(t, testutil.WaitShort) + workspace, err := client.CreateUserWorkspace(ctx, memberUser.Username, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: coderdtest.RandomUsername(t), + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + sent := enqueuer.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceCreated)) + require.Len(t, sent, 1) + require.Equal(t, memberUser.ID, sent[0].UserID) + require.Contains(t, sent[0].Targets, template.ID) + require.Contains(t, sent[0].Targets, workspace.ID) + require.Contains(t, sent[0].Targets, workspace.OrganizationID) + require.Contains(t, sent[0].Targets, workspace.OwnerID) + }) + t.Run("CreateWithAuditLogs", func(t *testing.T) { t.Parallel() auditor := audit.NewMock() @@ -3596,15 +3649,14 @@ func TestWorkspaceNotifications(t *testing.T) { // Then require.NoError(t, err, "mark workspace as dormant") - sent := notifyEnq.Sent() - require.Len(t, sent, 2) - // notifyEnq.Sent[0] is an event for created user account - require.Equal(t, sent[1].TemplateID, notifications.TemplateWorkspaceDormant) - require.Equal(t, sent[1].UserID, workspace.OwnerID) - require.Contains(t, sent[1].Targets, template.ID) - require.Contains(t, sent[1].Targets, workspace.ID) - require.Contains(t, sent[1].Targets, workspace.OrganizationID) - require.Contains(t, sent[1].Targets, workspace.OwnerID) + sent := notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceDormant)) + require.Len(t, sent, 1) + require.Equal(t, sent[0].TemplateID, notifications.TemplateWorkspaceDormant) + require.Equal(t, sent[0].UserID, workspace.OwnerID) + require.Contains(t, sent[0].Targets, template.ID) + require.Contains(t, sent[0].Targets, workspace.ID) + require.Contains(t, sent[0].Targets, workspace.OrganizationID) + require.Contains(t, sent[0].Targets, workspace.OwnerID) }) t.Run("InitiatorIsOwner", func(t *testing.T) { @@ -3635,7 +3687,7 @@ func TestWorkspaceNotifications(t *testing.T) { // Then require.NoError(t, err, "mark workspace as dormant") - require.Len(t, notifyEnq.Sent(), 0) + require.Len(t, notifyEnq.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceDormant)), 0) }) t.Run("ActivateDormantWorkspace", func(t *testing.T) {