diff --git a/coderd/database/migrations/000226_notifications_autobuild_failed.down.sql b/coderd/database/migrations/000226_notifications_autobuild_failed.down.sql new file mode 100644 index 0000000000000..6695445a90238 --- /dev/null +++ b/coderd/database/migrations/000226_notifications_autobuild_failed.down.sql @@ -0,0 +1 @@ +DELETE FROM notification_templates WHERE id = '381df2a9-c0c0-4749-420f-80a9280c66f9'; diff --git a/coderd/database/migrations/000226_notifications_autobuild_failed.up.sql b/coderd/database/migrations/000226_notifications_autobuild_failed.up.sql new file mode 100644 index 0000000000000..d5c2f3f4824fb --- /dev/null +++ b/coderd/database/migrations/000226_notifications_autobuild_failed.up.sql @@ -0,0 +1,9 @@ +INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions) +VALUES ('381df2a9-c0c0-4749-420f-80a9280c66f9', 'Workspace Autobuild Failed', E'Workspace "{{.Labels.name}}" autobuild failed', + E'Hi {{.UserName}}\n\Automatic build of your workspace **{{.Labels.name}}** failed.\nThe specified reason was "**{{.Labels.reason}}**".', + 'Workspace Events', '[ + { + "label": "View workspace", + "url": "{{ base_url }}/@{{.UserName}}/{{.Labels.name}}" + } + ]'::jsonb); diff --git a/coderd/notifications/enqueuer.go b/coderd/notifications/enqueuer.go index 8838ba9be1949..d73826142f7ca 100644 --- a/coderd/notifications/enqueuer.go +++ b/coderd/notifications/enqueuer.go @@ -80,7 +80,7 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI // buildPayload creates the payload that the notification will for variable substitution and/or routing. // The payload contains information about the recipient, the event that triggered the notification, and any subsequent // actions which can be taken by the recipient. -func (s *StoreEnqueuer) buildPayload(ctx context.Context, userID uuid.UUID, templateID uuid.UUID, labels map[string]string) (*types.MessagePayload, error) { +func (s *StoreEnqueuer) buildPayload(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string) (*types.MessagePayload, error) { metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ UserID: userID, NotificationTemplateID: templateID, @@ -89,8 +89,21 @@ func (s *StoreEnqueuer) buildPayload(ctx context.Context, userID uuid.UUID, temp return nil, xerrors.Errorf("new message metadata: %w", err) } + payload := types.MessagePayload{ + Version: "1.0", + + NotificationName: metadata.NotificationName, + + UserID: metadata.UserID.String(), + UserEmail: metadata.UserEmail, + UserName: metadata.UserName, + + Labels: labels, + // No actions yet + } + // Execute any templates in actions. - out, err := render.GoTemplate(string(metadata.Actions), types.MessagePayload{}, s.helpers) + out, err := render.GoTemplate(string(metadata.Actions), payload, s.helpers) if err != nil { return nil, xerrors.Errorf("render actions: %w", err) } @@ -100,19 +113,8 @@ func (s *StoreEnqueuer) buildPayload(ctx context.Context, userID uuid.UUID, temp if err = json.Unmarshal(metadata.Actions, &actions); err != nil { return nil, xerrors.Errorf("new message metadata: parse template actions: %w", err) } - - return &types.MessagePayload{ - Version: "1.0", - - NotificationName: metadata.NotificationName, - - UserID: metadata.UserID.String(), - UserEmail: metadata.UserEmail, - UserName: metadata.UserName, - - Actions: actions, - Labels: labels, - }, nil + payload.Actions = actions + return &payload, nil } // NoopEnqueuer implements the Enqueuer interface but performs a noop. diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 6cb2870748b61..59ff87f67eef9 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -6,4 +6,7 @@ import "github.com/google/uuid" // TODO: autogenerate these. // Workspace-related events. -var TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed") +var ( + TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed") + WorkspaceAutobuildFailed = uuid.MustParse("381df2a9-c0c0-4749-420f-80a9280c66f9") +) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index abf29ca7a4539..93ba158b48a65 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -98,10 +98,11 @@ func TestBuildPayload(t *testing.T) { // GIVEN: a set of helpers to be injected into the templates const label = "Click here!" - const url = "http://xyz.com/" + const baseURL = "http://xyz.com" + const url = baseURL + "/@bobby/my-workspace" helpers := map[string]any{ "my_label": func() string { return label }, - "my_url": func() string { return url }, + "my_url": func() string { return baseURL }, } // GIVEN: an enqueue interceptor which returns mock metadata @@ -112,7 +113,7 @@ func TestBuildPayload(t *testing.T) { actions := []types.TemplateAction{ { Label: "{{ my_label }}", - URL: "{{ my_url }}", + URL: "{{ my_url }}/@{{.UserName}}/{{.Labels.name}}", }, } out, err := json.Marshal(actions) @@ -131,7 +132,9 @@ func TestBuildPayload(t *testing.T) { require.NoError(t, err) // WHEN: a notification is enqueued - _, err = enq.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "test") + _, err = enq.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, map[string]string{ + "name": "my-workspace", + }, "test") require.NoError(t, err) // THEN: expect that a payload will be constructed and have the expected values diff --git a/coderd/notifications/render/gotmpl_test.go b/coderd/notifications/render/gotmpl_test.go index 32970dd6cd8b6..0cb95bccfcb43 100644 --- a/coderd/notifications/render/gotmpl_test.go +++ b/coderd/notifications/render/gotmpl_test.go @@ -38,6 +38,23 @@ func TestGoTemplate(t *testing.T) { expectedOutput: userEmail, expectedErr: nil, }, + { + name: "render workspace URL", + in: `[{ + "label": "View workspace", + "url": "{{ base_url }}/@{{.UserName}}/{{.Labels.name}}" + }]`, + payload: types.MessagePayload{ + UserName: "johndoe", + Labels: map[string]string{ + "name": "my-workspace", + }, + }, + expectedOutput: `[{ + "label": "View workspace", + "url": "https://mocked-server-address/@johndoe/my-workspace" + }]`, + }, } for _, tc := range tests { @@ -46,7 +63,9 @@ func TestGoTemplate(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() - out, err := render.GoTemplate(tc.in, tc.payload, nil) + out, err := render.GoTemplate(tc.in, tc.payload, map[string]any{ + "base_url": func() string { return "https://mocked-server-address" }, + }) if tc.expectedErr == nil { require.NoError(t, err) } else { diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 79185862daa2e..e8ec371b1c354 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -982,12 +982,18 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto. } var build database.WorkspaceBuild + var workspace database.Workspace err = s.Database.InTx(func(db database.Store) error { build, err = db.GetWorkspaceBuildByID(ctx, input.WorkspaceBuildID) if err != nil { return xerrors.Errorf("get workspace build: %w", err) } + workspace, err = db.GetWorkspaceByID(ctx, build.WorkspaceID) + if err != nil { + return xerrors.Errorf("get workspace: %w", err) + } + if jobType.WorkspaceBuild.State != nil { err = db.UpdateWorkspaceBuildProvisionerStateByID(ctx, database.UpdateWorkspaceBuildProvisionerStateByIDParams{ ID: input.WorkspaceBuildID, @@ -1014,6 +1020,8 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto. return nil, err } + s.notifyWorkspaceBuildFailed(ctx, workspace, build) + err = s.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), []byte{}) if err != nil { return nil, xerrors.Errorf("update workspace: %w", err) @@ -1087,6 +1095,27 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto. return &proto.Empty{}, nil } +func (s *server) notifyWorkspaceBuildFailed(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) { + var reason string + if build.Reason.Valid() && build.Reason == database.BuildReasonInitiator { + return // failed workspace build initiated by a user should not notify + } + reason = string(build.Reason) + initiator := "autobuild" + + if _, err := s.NotificationEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.WorkspaceAutobuildFailed, + map[string]string{ + "name": workspace.Name, + "initiator": initiator, + "reason": reason, + }, "provisionerdserver", + // Associate this notification with all the related entities. + workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID, + ); err != nil { + s.Logger.Warn(ctx, "failed to notify of failed workspace autobuild", slog.Error(err)) + } +} + // CompleteJob is triggered by a provision daemon to mark a provisioner job as completed. func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) (*proto.Empty, error) { ctx, span := s.startTrace(ctx, tracing.FuncName()) @@ -1523,6 +1552,7 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) { var reason string + initiator := build.InitiatorByUsername if build.Reason.Valid() { switch build.Reason { case database.BuildReasonInitiator: @@ -1534,6 +1564,7 @@ func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database. reason = "initiated by user" case database.BuildReasonAutodelete: reason = "autodeleted due to dormancy" + initiator = "autobuild" default: reason = string(build.Reason) } @@ -1545,9 +1576,9 @@ func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database. if _, err := s.NotificationEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceDeleted, map[string]string{ - "name": workspace.Name, - "initiatedBy": build.InitiatorByUsername, - "reason": reason, + "name": workspace.Name, + "reason": reason, + "initiator": initiator, }, "provisionerdserver", // Associate this notification with all the related entities. workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 7049359be98a7..8609d8a8cc170 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -1687,7 +1687,7 @@ func TestNotifications(t *testing.T) { require.Contains(t, notifEnq.sent[0].targets, workspace.OrganizationID) require.Contains(t, notifEnq.sent[0].targets, user.ID) if tc.deletionReason == database.BuildReasonInitiator { - require.Equal(t, notifEnq.sent[0].labels["initiatedBy"], initiator.Username) + require.Equal(t, initiator.Username, notifEnq.sent[0].labels["initiator"]) } } else { require.Len(t, notifEnq.sent, 0) @@ -1695,6 +1695,117 @@ func TestNotifications(t *testing.T) { }) } }) + + t.Run("Workspace build failed", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + + buildReason database.BuildReason + shouldNotify bool + }{ + { + name: "initiated by owner", + buildReason: database.BuildReasonInitiator, + shouldNotify: false, + }, + { + name: "initiated by autostart", + buildReason: database.BuildReasonAutostart, + shouldNotify: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + notifEnq := &fakeNotificationEnqueuer{} + + // Otherwise `(*Server).FailJob` fails with: + // audit log - get build {"error": "sql: no rows in result set"} + ignoreLogErrors := true + srv, db, ps, pd := setup(t, ignoreLogErrors, &overrides{ + notificationEnqueuer: notifEnq, + }) + + user := dbgen.User(t, db, database.User{}) + initiator := user + + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: pd.OrganizationID, + }) + template, err := db.GetTemplateByID(ctx, template.ID) + require.NoError(t, err) + file := dbgen.File(t, db, database.File{CreatedBy: user.ID}) + workspace := dbgen.Workspace(t, db, database.Workspace{ + TemplateID: template.ID, + OwnerID: user.ID, + OrganizationID: pd.OrganizationID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: uuid.New(), + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + InitiatorID: initiator.ID, + Transition: database.WorkspaceTransitionDelete, + Reason: tc.buildReason, + }) + job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + FileID: file.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + })), + OrganizationID: pd.OrganizationID, + }) + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + _, err = srv.FailJob(ctx, &proto.FailedJob{ + JobId: job.ID.String(), + Type: &proto.FailedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.FailedJob_WorkspaceBuild{ + State: []byte{}, + }, + }, + }) + require.NoError(t, err) + + if tc.shouldNotify { + // Validate that the notification was sent and contained the expected values. + require.Len(t, notifEnq.sent, 1) + require.Equal(t, notifEnq.sent[0].userID, user.ID) + require.Contains(t, notifEnq.sent[0].targets, template.ID) + require.Contains(t, notifEnq.sent[0].targets, workspace.ID) + require.Contains(t, notifEnq.sent[0].targets, workspace.OrganizationID) + require.Contains(t, notifEnq.sent[0].targets, user.ID) + require.Equal(t, "autobuild", notifEnq.sent[0].labels["initiator"]) + require.Equal(t, string(tc.buildReason), notifEnq.sent[0].labels["reason"]) + } else { + require.Len(t, notifEnq.sent, 0) + } + }) + } + }) } type overrides struct {