Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit f3fe3bc

Browse files
feat: notify on workspace update (#15979)
Relates to #15845 When the `/workspace/<name>/builds` endpoint is hit, we check if the requested template version is different to the previously used template version. If these values differ, we can assume that the workspace has been manually updated and send the appropriate notification. Automatic updates happen in the lifecycle executor and bypasses this endpoint entirely.
1 parent 445392b commit f3fe3bc

10 files changed

+397
-28
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DELETE FROM notification_templates WHERE id = 'd089fe7b-d5c5-4c0c-aaf5-689859f7d392';
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
INSERT INTO notification_templates
2+
(id, name, title_template, body_template, "group", actions)
3+
VALUES (
4+
'd089fe7b-d5c5-4c0c-aaf5-689859f7d392',
5+
'Workspace Manually Updated',
6+
E'Workspace ''{{.Labels.workspace}}'' has been manually updated',
7+
E'Hello {{.UserName}},\n\n'||
8+
E'A new workspace build has been manually created for your workspace **{{.Labels.workspace}}** by **{{.Labels.initiator}}** to update it to version **{{.Labels.version}}** of template **{{.Labels.template}}**.',
9+
'Workspace Events',
10+
'[
11+
{
12+
"label": "View workspace",
13+
"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}"
14+
},
15+
{
16+
"label": "View template version",
17+
"url": "{{base_url}}/templates/{{.Labels.organization}}/{{.Labels.template}}/versions/{{.Labels.version}}"
18+
}
19+
]'::jsonb
20+
);
21+
22+
UPDATE notification_templates
23+
SET
24+
actions = '[
25+
{
26+
"label": "View workspace",
27+
"url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}"
28+
}
29+
]'::jsonb
30+
WHERE id = '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff';

coderd/notifications/events.go

+1
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import "github.com/google/uuid"
88
// Workspace-related events.
99
var (
1010
TemplateWorkspaceCreated = uuid.MustParse("281fdf73-c6d6-4cbb-8ff5-888baf8a2fff")
11+
TemplateWorkspaceManuallyUpdated = uuid.MustParse("d089fe7b-d5c5-4c0c-aaf5-689859f7d392")
1112
TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed")
1213
TemplateWorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9")
1314
TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0")

coderd/notifications/notifications_test.go

+16
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,22 @@ func TestNotificationTemplates_Golden(t *testing.T) {
10481048
},
10491049
},
10501050
},
1051+
{
1052+
name: "TemplateWorkspaceManuallyUpdated",
1053+
id: notifications.TemplateWorkspaceManuallyUpdated,
1054+
payload: types.MessagePayload{
1055+
UserName: "Bobby",
1056+
UserEmail: "[email protected]",
1057+
UserUsername: "bobby",
1058+
Labels: map[string]string{
1059+
"organization": "bobby-organization",
1060+
"initiator": "bobby",
1061+
"workspace": "bobby-workspace",
1062+
"template": "bobby-template",
1063+
"version": "alpha",
1064+
},
1065+
},
1066+
},
10511067
}
10521068

10531069
// We must have a test case for every notification_template. This is enforced below:

coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden

+2-2
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ The workspace bobby-workspace has been created from the template bobby-temp=
1616
late using version alpha.
1717

1818

19-
See workspace: http://test.com/@bobby/bobby-workspace
19+
View workspace: http://test.com/@bobby/bobby-workspace
2020

2121
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
2222
Content-Transfer-Encoding: quoted-printable
@@ -57,7 +57,7 @@ ng>.</p>
5757
<a href=3D"http://test.com/@bobby/bobby-workspace" style=3D"display=
5858
: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fa=
5959
fc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
60-
See workspace
60+
View workspace
6161
</a>
6262
=20
6363
</div>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
2+
3+
Subject: Workspace 'bobby-workspace' has been manually updated
4+
Message-Id: 02ee4935-73be-4fa1-a290-ff9999026b13@blush-whale-48
5+
Date: Fri, 11 Oct 2024 09:03:06 +0000
6+
Content-Type: multipart/alternative; boundary=bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
7+
MIME-Version: 1.0
8+
9+
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
10+
Content-Transfer-Encoding: quoted-printable
11+
Content-Type: text/plain; charset=UTF-8
12+
13+
Hello Bobby,
14+
15+
A new workspace build has been manually created for your workspace bobby-wo=
16+
rkspace by bobby to update it to version alpha of template bobby-template.
17+
18+
19+
View workspace: http://test.com/@bobby/bobby-workspace
20+
21+
View template version: http://test.com/templates/bobby-organization/bobby-t=
22+
emplate/versions/alpha
23+
24+
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4
25+
Content-Transfer-Encoding: quoted-printable
26+
Content-Type: text/html; charset=UTF-8
27+
28+
<!doctype html>
29+
<html lang=3D"en">
30+
<head>
31+
<meta charset=3D"UTF-8" />
32+
<meta name=3D"viewport" content=3D"width=3Ddevice-width, initial-scale=
33+
=3D1.0" />
34+
<title>Workspace 'bobby-workspace' has been manually updated</title>
35+
</head>
36+
<body style=3D"margin: 0; padding: 0; font-family: -apple-system, system-=
37+
ui, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarel=
38+
l', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; color: #020617=
39+
; background: #f8fafc;">
40+
<div style=3D"max-width: 600px; margin: 20px auto; padding: 60px; borde=
41+
r: 1px solid #e2e8f0; border-radius: 8px; background-color: #fff; text-alig=
42+
n: left; font-size: 14px; line-height: 1.5;">
43+
<div style=3D"text-align: center;">
44+
<img src=3D"https://coder.com/coder-logo-horizontal.png" alt=3D"Cod=
45+
er Logo" style=3D"height: 40px;" />
46+
</div>
47+
<h1 style=3D"text-align: center; font-size: 24px; font-weight: 400; m=
48+
argin: 8px 0 32px; line-height: 1.5;">
49+
Workspace 'bobby-workspace' has been manually updated
50+
</h1>
51+
<div style=3D"line-height: 1.5;">
52+
<p>Hello Bobby,</p>
53+
54+
<p>A new workspace build has been manually created for your workspace <stro=
55+
ng>bobby-workspace</strong> by <strong>bobby</strong> to update it to versi=
56+
on <strong>alpha</strong> of template <strong>bobby-template</strong>.</p>
57+
</div>
58+
<div style=3D"text-align: center; margin-top: 32px;">
59+
=20
60+
<a href=3D"http://test.com/@bobby/bobby-workspace" style=3D"display=
61+
: inline-block; padding: 13px 24px; background-color: #020617; color: #f8fa=
62+
fc; text-decoration: none; border-radius: 8px; margin: 0 4px;">
63+
View workspace
64+
</a>
65+
=20
66+
<a href=3D"http://test.com/templates/bobby-organization/bobby-templ=
67+
ate/versions/alpha" style=3D"display: inline-block; padding: 13px 24px; bac=
68+
kground-color: #020617; color: #f8fafc; text-decoration: none; border-radiu=
69+
s: 8px; margin: 0 4px;">
70+
View template version
71+
</a>
72+
=20
73+
</div>
74+
<div style=3D"border-top: 1px solid #e2e8f0; color: #475569; font-siz=
75+
e: 12px; margin-top: 64px; padding-top: 24px; line-height: 1.6;">
76+
<p>&copy;&nbsp;2024&nbsp;Coder. All rights reserved&nbsp;-&nbsp;<a =
77+
href=3D"http://test.com" style=3D"color: #2563eb; text-decoration: none;">h=
78+
ttp://test.com</a></p>
79+
<p><a href=3D"http://test.com/settings/notifications" style=3D"colo=
80+
r: #2563eb; text-decoration: none;">Click here to manage your notification =
81+
settings</a></p>
82+
<p><a href=3D"http://test.com/settings/notifications?disabled=3Dd08=
83+
9fe7b-d5c5-4c0c-aaf5-689859f7d392" style=3D"color: #2563eb; text-decoration=
84+
: none;">Stop receiving emails like this</a></p>
85+
</div>
86+
</div>
87+
</body>
88+
</html>
89+
90+
--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4--

coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"user_username": "bobby",
1212
"actions": [
1313
{
14-
"label": "See workspace",
14+
"label": "View workspace",
1515
"url": "http://test.com/@bobby/bobby-workspace"
1616
}
1717
],
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
{
2+
"_version": "1.1",
3+
"msg_id": "00000000-0000-0000-0000-000000000000",
4+
"payload": {
5+
"_version": "1.1",
6+
"notification_name": "Workspace Manually Updated",
7+
"notification_template_id": "00000000-0000-0000-0000-000000000000",
8+
"user_id": "00000000-0000-0000-0000-000000000000",
9+
"user_email": "[email protected]",
10+
"user_name": "Bobby",
11+
"user_username": "bobby",
12+
"actions": [
13+
{
14+
"label": "View workspace",
15+
"url": "http://test.com/@bobby/bobby-workspace"
16+
},
17+
{
18+
"label": "View template version",
19+
"url": "http://test.com/templates/bobby-organization/bobby-template/versions/alpha"
20+
}
21+
],
22+
"labels": {
23+
"initiator": "bobby",
24+
"organization": "bobby-organization",
25+
"template": "bobby-template",
26+
"version": "alpha",
27+
"workspace": "bobby-workspace"
28+
},
29+
"data": null
30+
},
31+
"title": "Workspace 'bobby-workspace' has been manually updated",
32+
"title_markdown": "Workspace 'bobby-workspace' has been manually updated",
33+
"body": "Hello Bobby,\n\nA new workspace build has been manually created for your workspace bobby-workspace by bobby to update it to version alpha of template bobby-template.",
34+
"body_markdown": "Hello Bobby,\n\nA new workspace build has been manually created for your workspace **bobby-workspace** by **bobby** to update it to version **alpha** of template **bobby-template**."
35+
}

coderd/workspacebuilds.go

+121-25
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/coder/coder/v2/coderd/database/provisionerjobs"
2828
"github.com/coder/coder/v2/coderd/httpapi"
2929
"github.com/coder/coder/v2/coderd/httpmw"
30+
"github.com/coder/coder/v2/coderd/notifications"
3031
"github.com/coder/coder/v2/coderd/provisionerdserver"
3132
"github.com/coder/coder/v2/coderd/rbac"
3233
"github.com/coder/coder/v2/coderd/rbac/policy"
@@ -333,37 +334,59 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
333334
LogLevel(string(createBuild.LogLevel)).
334335
DeploymentValues(api.Options.DeploymentValues)
335336

336-
if createBuild.TemplateVersionID != uuid.Nil {
337-
builder = builder.VersionID(createBuild.TemplateVersionID)
338-
}
337+
var (
338+
previousWorkspaceBuild database.WorkspaceBuild
339+
workspaceBuild *database.WorkspaceBuild
340+
provisionerJob *database.ProvisionerJob
341+
provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow
342+
)
339343

340-
if createBuild.Orphan {
341-
if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
342-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
343-
Message: "Orphan is only permitted when deleting a workspace.",
344+
err := api.Database.InTx(func(tx database.Store) error {
345+
var err error
346+
347+
previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
348+
if err != nil && !xerrors.Is(err, sql.ErrNoRows) {
349+
api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err))
350+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
351+
Message: "Internal error fetching previous workspace build",
352+
Detail: err.Error(),
344353
})
345-
return
354+
return nil
355+
}
356+
357+
if createBuild.TemplateVersionID != uuid.Nil {
358+
builder = builder.VersionID(createBuild.TemplateVersionID)
359+
}
360+
361+
if createBuild.Orphan {
362+
if createBuild.Transition != codersdk.WorkspaceTransitionDelete {
363+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
364+
Message: "Orphan is only permitted when deleting a workspace.",
365+
})
366+
return nil
367+
}
368+
if len(createBuild.ProvisionerState) > 0 {
369+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
370+
Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
371+
})
372+
return nil
373+
}
374+
builder = builder.Orphan()
346375
}
347376
if len(createBuild.ProvisionerState) > 0 {
348-
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
349-
Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.",
350-
})
351-
return
377+
builder = builder.State(createBuild.ProvisionerState)
352378
}
353-
builder = builder.Orphan()
354-
}
355-
if len(createBuild.ProvisionerState) > 0 {
356-
builder = builder.State(createBuild.ProvisionerState)
357-
}
358379

359-
workspaceBuild, provisionerJob, provisionerDaemons, err := builder.Build(
360-
ctx,
361-
api.Database,
362-
func(action policy.Action, object rbac.Objecter) bool {
363-
return api.Authorize(r, action, object)
364-
},
365-
audit.WorkspaceBuildBaggageFromRequest(r),
366-
)
380+
workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build(
381+
ctx,
382+
tx,
383+
func(action policy.Action, object rbac.Objecter) bool {
384+
return api.Authorize(r, action, object)
385+
},
386+
audit.WorkspaceBuildBaggageFromRequest(r),
387+
)
388+
return err
389+
}, nil)
367390
var buildErr wsbuilder.BuildError
368391
if xerrors.As(err, &buildErr) {
369392
var authErr dbauthz.NotAuthorizedError
@@ -420,6 +443,12 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
420443
return
421444
}
422445

446+
// If this workspace build has a different template version ID to the previous build
447+
// we can assume it has just been updated.
448+
if createBuild.TemplateVersionID != uuid.Nil && createBuild.TemplateVersionID != previousWorkspaceBuild.TemplateVersionID {
449+
api.notifyWorkspaceUpdated(ctx, apiKey.UserID, workspace, createBuild.RichParameterValues)
450+
}
451+
423452
api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{
424453
Kind: wspubsub.WorkspaceEventKindStateChange,
425454
WorkspaceID: workspace.ID,
@@ -428,6 +457,73 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) {
428457
httpapi.Write(ctx, rw, http.StatusCreated, apiBuild)
429458
}
430459

460+
func (api *API) notifyWorkspaceUpdated(
461+
ctx context.Context,
462+
initiatorID uuid.UUID,
463+
workspace database.Workspace,
464+
parameters []codersdk.WorkspaceBuildParameter,
465+
) {
466+
log := api.Logger.With(slog.F("workspace_id", workspace.ID))
467+
468+
template, err := api.Database.GetTemplateByID(ctx, workspace.TemplateID)
469+
if err != nil {
470+
log.Warn(ctx, "failed to fetch template for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
471+
return
472+
}
473+
474+
version, err := api.Database.GetTemplateVersionByID(ctx, template.ActiveVersionID)
475+
if err != nil {
476+
log.Warn(ctx, "failed to fetch template version for workspace creation notification", slog.F("template_id", workspace.TemplateID), slog.Error(err))
477+
return
478+
}
479+
480+
initiator, err := api.Database.GetUserByID(ctx, initiatorID)
481+
if err != nil {
482+
log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("initiator_id", initiatorID), slog.Error(err))
483+
return
484+
}
485+
486+
owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID)
487+
if err != nil {
488+
log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("owner_id", workspace.OwnerID), slog.Error(err))
489+
return
490+
}
491+
492+
buildParameters := make([]map[string]any, len(parameters))
493+
for idx, parameter := range parameters {
494+
buildParameters[idx] = map[string]any{
495+
"name": parameter.Name,
496+
"value": parameter.Value,
497+
}
498+
}
499+
500+
if _, err := api.NotificationsEnqueuer.EnqueueWithData(
501+
// nolint:gocritic // Need notifier actor to enqueue notifications
502+
dbauthz.AsNotifier(ctx),
503+
workspace.OwnerID,
504+
notifications.TemplateWorkspaceManuallyUpdated,
505+
map[string]string{
506+
"organization": template.OrganizationName,
507+
"initiator": initiator.Name,
508+
"workspace": workspace.Name,
509+
"template": template.Name,
510+
"version": version.Name,
511+
},
512+
map[string]any{
513+
"workspace": map[string]any{"id": workspace.ID, "name": workspace.Name},
514+
"template": map[string]any{"id": template.ID, "name": template.Name},
515+
"template_version": map[string]any{"id": version.ID, "name": version.Name},
516+
"owner": map[string]any{"id": owner.ID, "name": owner.Name},
517+
"parameters": buildParameters,
518+
},
519+
"api-workspaces-updated",
520+
// Associate this notification with all the related entities
521+
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,
522+
); err != nil {
523+
log.Warn(ctx, "failed to notify of workspace update", slog.Error(err))
524+
}
525+
}
526+
431527
// @Summary Cancel workspace build
432528
// @ID cancel-workspace-build
433529
// @Security CoderSessionToken

0 commit comments

Comments
 (0)