mirror of
https://github.com/coder/coder.git
synced 2025-07-29 10:35:52 +00:00
feat: notify on workspace creation (#15934)
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@@ -0,0 +1 @@
|
||||
DELETE FROM notification_templates WHERE id = '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff';
|
@@ -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
|
||||
);
|
@@ -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")
|
||||
|
@@ -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:
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
80
coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden
vendored
Normal file
80
coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden
vendored
Normal 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>© 2024 Coder. All rights reserved - <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--
|
29
coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden
vendored
Normal file
29
coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden
vendored
Normal 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**."
|
||||
}
|
@@ -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
|
||||
|
@@ -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) {
|
||||
|
Reference in New Issue
Block a user