diff --git a/coderd/database/migrations/000280_workspace_update_notification.down.sql b/coderd/database/migrations/000280_workspace_update_notification.down.sql new file mode 100644 index 0000000000000..5097c0248fe9b --- /dev/null +++ b/coderd/database/migrations/000280_workspace_update_notification.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = 'd089fe7b-d5c5-4c0c-aaf5-689859f7d392'; diff --git a/coderd/database/migrations/000280_workspace_update_notification.up.sql b/coderd/database/migrations/000280_workspace_update_notification.up.sql new file mode 100644 index 0000000000000..23d2331a323f6 --- /dev/null +++ b/coderd/database/migrations/000280_workspace_update_notification.up.sql @@ -0,0 +1,30 @@ +INSERT INTO notification_templates + (id, name, title_template, body_template, "group", actions) +VALUES ( + 'd089fe7b-d5c5-4c0c-aaf5-689859f7d392', + 'Workspace Manually Updated', + E'Workspace ''{{.Labels.workspace}}'' has been manually updated', + E'Hello {{.UserName}},\n\n'|| + 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}}**.', + 'Workspace Events', + '[ + { + "label": "View workspace", + "url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}" + }, + { + "label": "View template version", + "url": "{{base_url}}/templates/{{.Labels.organization}}/{{.Labels.template}}/versions/{{.Labels.version}}" + } + ]'::jsonb +); + +UPDATE notification_templates +SET + actions = '[ + { + "label": "View workspace", + "url": "{{base_url}}/@{{.UserUsername}}/{{.Labels.workspace}}" + } + ]'::jsonb +WHERE id = '281fdf73-c6d6-4cbb-8ff5-888baf8a2fff'; diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 12aecbaac74ae..754d2e5c7f745 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -8,6 +8,7 @@ import "github.com/google/uuid" // Workspace-related events. var ( TemplateWorkspaceCreated = uuid.MustParse("281fdf73-c6d6-4cbb-8ff5-888baf8a2fff") + TemplateWorkspaceManuallyUpdated = uuid.MustParse("d089fe7b-d5c5-4c0c-aaf5-689859f7d392") 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 90cf1a46be28a..1c4be51974b05 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1048,6 +1048,22 @@ func TestNotificationTemplates_Golden(t *testing.T) { }, }, }, + { + name: "TemplateWorkspaceManuallyUpdated", + id: notifications.TemplateWorkspaceManuallyUpdated, + payload: types.MessagePayload{ + UserName: "Bobby", + UserEmail: "bobby@coder.com", + UserUsername: "bobby", + Labels: map[string]string{ + "organization": "bobby-organization", + "initiator": "bobby", + "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/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden index 000b2a71ac77b..9d039ea7f77e9 100644 --- a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceCreated.html.golden @@ -16,7 +16,7 @@ The workspace bobby-workspace has been created from the template bobby-temp= late using version alpha. -See workspace: http://test.com/@bobby/bobby-workspace +View workspace: http://test.com/@bobby/bobby-workspace --bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 Content-Transfer-Encoding: quoted-printable @@ -57,7 +57,7 @@ ng>.

- See workspace + View workspace =20 diff --git a/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden new file mode 100644 index 0000000000000..57a9a0d51b7b7 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/smtp/TemplateWorkspaceManuallyUpdated.html.golden @@ -0,0 +1,90 @@ +From: system@coder.com +To: bobby@coder.com +Subject: Workspace 'bobby-workspace' has been manually updated +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, + +A new workspace build has been manually created for your workspace bobby-wo= +rkspace by bobby to update it to version alpha of template bobby-template. + + +View workspace: http://test.com/@bobby/bobby-workspace + +View template version: http://test.com/templates/bobby-organization/bobby-t= +emplate/versions/alpha + +--bbe61b741255b6098bb6b3c1f41b885773df633cb18d2a3002b68e4bc9c4 +Content-Transfer-Encoding: quoted-printable +Content-Type: text/html; charset=UTF-8 + + + + + + + Workspace 'bobby-workspace' has been manually updated + + +
+
+ 3D"Cod= +
+

+ Workspace 'bobby-workspace' has been manually updated +

+
+

Hello Bobby,

+ +

A new workspace build has been manually created for your workspace bobby-workspace by bobby to update it to versi= +on alpha of template bobby-template.

+
+
+ =20 + + View workspace + + =20 + + View template version + + =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 index 46354c4ffeef9..924f299b228b2 100644 --- a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceCreated.json.golden @@ -11,7 +11,7 @@ "user_username": "bobby", "actions": [ { - "label": "See workspace", + "label": "View workspace", "url": "http://test.com/@bobby/bobby-workspace" } ], diff --git a/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceManuallyUpdated.json.golden b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceManuallyUpdated.json.golden new file mode 100644 index 0000000000000..7fbda32e194f4 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/webhook/TemplateWorkspaceManuallyUpdated.json.golden @@ -0,0 +1,35 @@ +{ + "_version": "1.1", + "msg_id": "00000000-0000-0000-0000-000000000000", + "payload": { + "_version": "1.1", + "notification_name": "Workspace Manually Updated", + "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": "View workspace", + "url": "http://test.com/@bobby/bobby-workspace" + }, + { + "label": "View template version", + "url": "http://test.com/templates/bobby-organization/bobby-template/versions/alpha" + } + ], + "labels": { + "initiator": "bobby", + "organization": "bobby-organization", + "template": "bobby-template", + "version": "alpha", + "workspace": "bobby-workspace" + }, + "data": null + }, + "title": "Workspace 'bobby-workspace' has been manually updated", + "title_markdown": "Workspace 'bobby-workspace' has been manually updated", + "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.", + "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**." +} \ No newline at end of file diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 7eb598a7d4564..0a19f7dfdaf0a 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/database/provisionerjobs" "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/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -333,37 +334,59 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { LogLevel(string(createBuild.LogLevel)). DeploymentValues(api.Options.DeploymentValues) - if createBuild.TemplateVersionID != uuid.Nil { - builder = builder.VersionID(createBuild.TemplateVersionID) - } + var ( + previousWorkspaceBuild database.WorkspaceBuild + workspaceBuild *database.WorkspaceBuild + provisionerJob *database.ProvisionerJob + provisionerDaemons []database.GetEligibleProvisionerDaemonsByProvisionerJobIDsRow + ) - if createBuild.Orphan { - if createBuild.Transition != codersdk.WorkspaceTransitionDelete { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "Orphan is only permitted when deleting a workspace.", + err := api.Database.InTx(func(tx database.Store) error { + var err error + + previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err)) + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error fetching previous workspace build", + Detail: err.Error(), }) - return + return nil + } + + if createBuild.TemplateVersionID != uuid.Nil { + builder = builder.VersionID(createBuild.TemplateVersionID) + } + + if createBuild.Orphan { + if createBuild.Transition != codersdk.WorkspaceTransitionDelete { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Orphan is only permitted when deleting a workspace.", + }) + return nil + } + if len(createBuild.ProvisionerState) > 0 { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.", + }) + return nil + } + builder = builder.Orphan() } if len(createBuild.ProvisionerState) > 0 { - httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ - Message: "ProvisionerState cannot be set alongside Orphan since state intent is unclear.", - }) - return + builder = builder.State(createBuild.ProvisionerState) } - builder = builder.Orphan() - } - if len(createBuild.ProvisionerState) > 0 { - builder = builder.State(createBuild.ProvisionerState) - } - workspaceBuild, provisionerJob, provisionerDaemons, err := builder.Build( - ctx, - api.Database, - func(action policy.Action, object rbac.Objecter) bool { - return api.Authorize(r, action, object) - }, - audit.WorkspaceBuildBaggageFromRequest(r), - ) + workspaceBuild, provisionerJob, provisionerDaemons, err = builder.Build( + ctx, + tx, + func(action policy.Action, object rbac.Objecter) bool { + return api.Authorize(r, action, object) + }, + audit.WorkspaceBuildBaggageFromRequest(r), + ) + return err + }, nil) var buildErr wsbuilder.BuildError if xerrors.As(err, &buildErr) { var authErr dbauthz.NotAuthorizedError @@ -420,6 +443,12 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { return } + // If this workspace build has a different template version ID to the previous build + // we can assume it has just been updated. + if createBuild.TemplateVersionID != uuid.Nil && createBuild.TemplateVersionID != previousWorkspaceBuild.TemplateVersionID { + api.notifyWorkspaceUpdated(ctx, apiKey.UserID, workspace, createBuild.RichParameterValues) + } + api.publishWorkspaceUpdate(ctx, workspace.OwnerID, wspubsub.WorkspaceEvent{ Kind: wspubsub.WorkspaceEventKindStateChange, WorkspaceID: workspace.ID, @@ -428,6 +457,73 @@ func (api *API) postWorkspaceBuilds(rw http.ResponseWriter, r *http.Request) { httpapi.Write(ctx, rw, http.StatusCreated, apiBuild) } +func (api *API) notifyWorkspaceUpdated( + ctx context.Context, + initiatorID uuid.UUID, + 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 + } + + 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_id", workspace.TemplateID), slog.Error(err)) + return + } + + initiator, err := api.Database.GetUserByID(ctx, initiatorID) + if err != nil { + log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("initiator_id", initiatorID), slog.Error(err)) + return + } + + owner, err := api.Database.GetUserByID(ctx, workspace.OwnerID) + if err != nil { + log.Warn(ctx, "failed to fetch user for workspace update notification", slog.F("owner_id", workspace.OwnerID), 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.TemplateWorkspaceManuallyUpdated, + map[string]string{ + "organization": template.OrganizationName, + "initiator": initiator.Name, + "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-updated", + // 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 update", slog.Error(err)) + } +} + // @Summary Cancel workspace build // @ID cancel-workspace-build // @Security CoderSessionToken diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index feb748ad29250..43674b308583c 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -27,6 +27,8 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/externalauth" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/notificationstest" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisioner/echo" @@ -560,6 +562,104 @@ func TestWorkspaceBuildResources(t *testing.T) { }) } +func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) { + t.Parallel() + + t.Run("OnlyOneNotification", func(t *testing.T) { + t.Parallel() + + notify := ¬ificationstest.FakeEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, NotificationsEnqueuer: notify}) + first := coderdtest.CreateFirstUser(t, client) + userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + // Create a template with an initial version + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + + // Create a workspace using this template + workspace := coderdtest.CreateWorkspace(t, userClient, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // Create a new version of the template + newVersion := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, newVersion.ID) + + // Create a workspace build using this new template version + build := coderdtest.CreateWorkspaceBuild(t, userClient, workspace, database.WorkspaceTransitionStart, func(cwbr *codersdk.CreateWorkspaceBuildRequest) { + cwbr.TemplateVersionID = newVersion.ID + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // Create the workspace build _again_. We are doing this to ensure we only create 1 notification. + build = coderdtest.CreateWorkspaceBuild(t, userClient, workspace, database.WorkspaceTransitionStart, func(cwbr *codersdk.CreateWorkspaceBuildRequest) { + cwbr.TemplateVersionID = newVersion.ID + }) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // Ensure we receive only 1 workspace manually updated notification + sent := notify.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceManuallyUpdated)) + require.Len(t, sent, 1) + require.Equal(t, user.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("ToCorrectUser", func(t *testing.T) { + t.Parallel() + + notify := ¬ificationstest.FakeEnqueuer{} + + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, NotificationsEnqueuer: notify}) + first := coderdtest.CreateFirstUser(t, client) + userClient, user := coderdtest.CreateAnotherUser(t, client, first.OrganizationID) + + // Create a template with an initial version + version := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, first.OrganizationID, version.ID) + + // Create a workspace using this template + workspace := coderdtest.CreateWorkspace(t, userClient, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // Create a new version of the template + newVersion := coderdtest.CreateTemplateVersion(t, client, first.OrganizationID, nil, func(ctvr *codersdk.CreateTemplateVersionRequest) { + ctvr.TemplateID = template.ID + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, newVersion.ID) + + // Create a workspace build using this new template version from a different user + ctx := testutil.Context(t, testutil.WaitShort) + build, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + Transition: codersdk.WorkspaceTransitionStart, + TemplateVersionID: newVersion.ID, + }) + require.NoError(t, err) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, build.ID) + coderdtest.MustTransitionWorkspace(t, userClient, workspace.ID, database.WorkspaceTransitionStart, database.WorkspaceTransitionStop) + + // Ensure we receive only 1 workspace manually updated notification and to the right user + sent := notify.Sent(notificationstest.WithTemplateID(notifications.TemplateWorkspaceManuallyUpdated)) + require.Len(t, sent, 1) + require.Equal(t, user.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) + }) +} + func assertWorkspaceResource(t *testing.T, actual codersdk.WorkspaceResource, name, aType string, numAgents int) { assert.Equal(t, name, actual.Name) assert.Equal(t, aType, actual.Type)