feat: notify on workspace creation (#15934)

This commit is contained in:
Danielle Maywood
2024-12-20 13:53:10 +00:00
committed by GitHub
parent f5d3f713c6
commit f0e81ab455
10 changed files with 291 additions and 14 deletions

View File

@@ -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)
}
})
}

View File

@@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff';

View File

@@ -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
);

View File

@@ -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")

View File

@@ -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:

View File

@@ -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
}
}

View File

@@ -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
<!doctype html>
<html lang=3D"en">
<head>
<meta charset=3D"UTF-8" />
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
=3D1.0" />
<title>Workspace 'bobby-workspace' has been created</title>
</head>
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
; background: #f8fafc;">
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
n: left; font-size: 14px; line-height: 1.5;">
<div style=3D"text-align: center;">
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
er Logo" style=3D"height: 40px;" />
</div>
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
argin: 8px 0 32px; line-height: 1.5;">
Workspace 'bobby-workspace' has been created
</h1>
<div style=3D"line-height: 1.5;">
<p>Hello Bobby,</p>
<p>The workspace <strong>bobby-workspace</strong> has been created from the=
template <strong>bobby-template</strong> using version <strong>alpha</stro=
ng>.</p>
</div>
<div style=3D"text-align: center; margin-top: 32px;">
=20
<a href=3D"http://test.com/@bobby/bobby-workspace" style=3D"display=
: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fa=
fc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
See workspace
</a>
=20
</div>
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
ttp://test.com</a></p>
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
r: #2563eb; text-decoration: none;">Click here to manage your notification =
settings</a></p>
<p><a href=3D"http://test.com/settings/notifications?disabled=3D281=
fdf73-c6d6-4cbb-8ff5-888baf8a2fff" style=3D"color: #2563eb; text-decoration=
: none;">Stop receiving emails like this</a></p>
</div>
</div>
</body>
</html>
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--

View File

@@ -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**."
}

View File

@@ -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

View File

@@ -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) {