Skip to content

Commit 47f2c7d

Browse files
authored
feat: notify about manual failed builds (#14419)
1 parent 0afff43 commit 47f2c7d

File tree

6 files changed

+172
-4
lines changed

6 files changed

+172
-4
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
DELETE FROM notification_templates WHERE id = '2faeee0f-26cb-4e96-821c-85ccb9f71513';
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions)
2+
VALUES ('2faeee0f-26cb-4e96-821c-85ccb9f71513', 'Workspace Manual Build Failed', E'Workspace "{{.Labels.name}}" manual build failed',
3+
E'Hi {{.UserName}},\n\nA manual build of the workspace **{{.Labels.name}}** using the template **{{.Labels.template_name}}** failed (version: **{{.Labels.template_version_name}}**).\nThe workspace build was initiated by **{{.Labels.initiator}}**.',
4+
'Workspace Events', '[
5+
{
6+
"label": "View build",
7+
"url": "{{ base_url }}/@{{.Labels.workspace_owner_username}}/{{.Labels.name}}/builds/{{.Labels.workspace_build_number}}"
8+
}
9+
]'::jsonb);

coderd/notifications/events.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ var (
1212
TemplateWorkspaceDormant = uuid.MustParse("0ea69165-ec14-4314-91f1-69566ac3c5a0")
1313
TemplateWorkspaceAutoUpdated = uuid.MustParse("c34a0c09-0704-4cac-bd1c-0c0146811c2b")
1414
TemplateWorkspaceMarkedForDeletion = uuid.MustParse("51ce2fdf-c9ca-4be1-8d70-628674f9bc42")
15+
TemplateWorkspaceManualBuildFailed = uuid.MustParse("2faeee0f-26cb-4e96-821c-85ccb9f71513")
1516
)
1617

1718
// Account-related events.

coderd/notifications/notifications_test.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -814,6 +814,21 @@ func TestNotificationTemplatesCanRender(t *testing.T) {
814814
},
815815
},
816816
},
817+
{
818+
name: "TemplateWorkspaceManualBuildFailed",
819+
id: notifications.TemplateWorkspaceManualBuildFailed,
820+
payload: types.MessagePayload{
821+
UserName: "bobby",
822+
Labels: map[string]string{
823+
"name": "bobby-workspace",
824+
"template_name": "bobby-template",
825+
"template_version_name": "bobby-template-version",
826+
"initiator": "joe",
827+
"workspace_owner_username": "mrbobby",
828+
"workspace_build_number": "3",
829+
},
830+
},
831+
},
817832
}
818833

819834
allTemplates, err := enumerateAllTemplates(t)

coderd/provisionerdserver/provisionerdserver.go

Lines changed: 82 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"net/http"
1010
"net/url"
1111
"reflect"
12+
"sort"
1213
"strconv"
1314
"strings"
1415
"sync/atomic"
@@ -1098,7 +1099,8 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto.
10981099
func (s *server) notifyWorkspaceBuildFailed(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) {
10991100
var reason string
11001101
if build.Reason.Valid() && build.Reason == database.BuildReasonInitiator {
1101-
return // failed workspace build initiated by a user should not notify
1102+
s.notifyWorkspaceManualBuildFailed(ctx, workspace, build)
1103+
return
11021104
}
11031105
reason = string(build.Reason)
11041106

@@ -1114,6 +1116,85 @@ func (s *server) notifyWorkspaceBuildFailed(ctx context.Context, workspace datab
11141116
}
11151117
}
11161118

1119+
func (s *server) notifyWorkspaceManualBuildFailed(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) {
1120+
templateAdmins, template, templateVersion, workspaceOwner, err := s.prepareForNotifyWorkspaceManualBuildFailed(ctx, workspace, build)
1121+
if err != nil {
1122+
s.Logger.Error(ctx, "unable to collect data for manual build failed notification", slog.Error(err))
1123+
return
1124+
}
1125+
1126+
for _, templateAdmin := range templateAdmins {
1127+
if _, err := s.NotificationsEnqueuer.Enqueue(ctx, templateAdmin.ID, notifications.TemplateWorkspaceManualBuildFailed,
1128+
map[string]string{
1129+
"name": workspace.Name,
1130+
"template_name": template.Name,
1131+
"template_version_name": templateVersion.Name,
1132+
"initiator": build.InitiatorByUsername,
1133+
"workspace_owner_username": workspaceOwner.Username,
1134+
"workspace_build_number": strconv.Itoa(int(build.BuildNumber)),
1135+
}, "provisionerdserver",
1136+
// Associate this notification with all the related entities.
1137+
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID,
1138+
); err != nil {
1139+
s.Logger.Warn(ctx, "failed to notify of failed workspace manual build", slog.Error(err))
1140+
}
1141+
}
1142+
}
1143+
1144+
// prepareForNotifyWorkspaceManualBuildFailed collects data required to build notifications for template admins.
1145+
// The template `notifications.TemplateWorkspaceManualBuildFailed` is quite detailed as it requires information about the template,
1146+
// template version, workspace, workspace build, etc.
1147+
func (s *server) prepareForNotifyWorkspaceManualBuildFailed(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) ([]database.GetUsersRow,
1148+
database.Template, database.TemplateVersion, database.User, error,
1149+
) {
1150+
users, err := s.Database.GetUsers(ctx, database.GetUsersParams{
1151+
RbacRole: []string{codersdk.RoleTemplateAdmin},
1152+
})
1153+
if err != nil {
1154+
return nil, database.Template{}, database.TemplateVersion{}, database.User{}, xerrors.Errorf("unable to fetch template admins: %w", err)
1155+
}
1156+
1157+
usersByIDs := map[uuid.UUID]database.GetUsersRow{}
1158+
var userIDs []uuid.UUID
1159+
for _, user := range users {
1160+
usersByIDs[user.ID] = user
1161+
userIDs = append(userIDs, user.ID)
1162+
}
1163+
1164+
var templateAdmins []database.GetUsersRow
1165+
if len(userIDs) > 0 {
1166+
orgIDsByMemberIDs, err := s.Database.GetOrganizationIDsByMemberIDs(ctx, userIDs)
1167+
if err != nil {
1168+
return nil, database.Template{}, database.TemplateVersion{}, database.User{}, xerrors.Errorf("unable to fetch organization IDs by member IDs: %w", err)
1169+
}
1170+
1171+
for _, entry := range orgIDsByMemberIDs {
1172+
if slices.Contains(entry.OrganizationIDs, workspace.OrganizationID) {
1173+
templateAdmins = append(templateAdmins, usersByIDs[entry.UserID])
1174+
}
1175+
}
1176+
}
1177+
sort.Slice(templateAdmins, func(i, j int) bool {
1178+
return templateAdmins[i].Username < templateAdmins[j].Username
1179+
})
1180+
1181+
template, err := s.Database.GetTemplateByID(ctx, workspace.TemplateID)
1182+
if err != nil {
1183+
return nil, database.Template{}, database.TemplateVersion{}, database.User{}, xerrors.Errorf("unable to fetch template: %w", err)
1184+
}
1185+
1186+
templateVersion, err := s.Database.GetTemplateVersionByID(ctx, build.TemplateVersionID)
1187+
if err != nil {
1188+
return nil, database.Template{}, database.TemplateVersion{}, database.User{}, xerrors.Errorf("unable to fetch template version: %w", err)
1189+
}
1190+
1191+
workspaceOwner, err := s.Database.GetUserByID(ctx, workspace.OwnerID)
1192+
if err != nil {
1193+
return nil, database.Template{}, database.TemplateVersion{}, database.User{}, xerrors.Errorf("unable to fetch workspace owner: %w", err)
1194+
}
1195+
return templateAdmins, template, templateVersion, workspaceOwner, nil
1196+
}
1197+
11171198
// CompleteJob is triggered by a provision daemon to mark a provisioner job as completed.
11181199
func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) (*proto.Empty, error) {
11191200
ctx, span := s.startTrace(ctx, tracing.FuncName())

coderd/provisionerdserver/provisionerdserver_test.go

Lines changed: 64 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import (
66
"encoding/json"
77
"io"
88
"net/url"
9+
"strconv"
910
"strings"
1011
"sync"
1112
"sync/atomic"
@@ -1738,8 +1739,6 @@ func TestNotifications(t *testing.T) {
17381739
Provisioner: database.ProvisionerTypeEcho,
17391740
OrganizationID: pd.OrganizationID,
17401741
})
1741-
template, err := db.GetTemplateByID(ctx, template.ID)
1742-
require.NoError(t, err)
17431742
file := dbgen.File(t, db, database.File{CreatedBy: user.ID})
17441743
workspace := dbgen.Workspace(t, db, database.Workspace{
17451744
TemplateID: template.ID,
@@ -1769,7 +1768,7 @@ func TestNotifications(t *testing.T) {
17691768
})),
17701769
OrganizationID: pd.OrganizationID,
17711770
})
1772-
_, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
1771+
_, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
17731772
OrganizationID: pd.OrganizationID,
17741773
WorkerID: uuid.NullUUID{
17751774
UUID: pd.ID,
@@ -1804,6 +1803,68 @@ func TestNotifications(t *testing.T) {
18041803
})
18051804
}
18061805
})
1806+
1807+
t.Run("Manual build failed, template admins notified", func(t *testing.T) {
1808+
t.Parallel()
1809+
1810+
ctx := context.Background()
1811+
1812+
// given
1813+
notifEnq := &testutil.FakeNotificationsEnqueuer{}
1814+
srv, db, ps, pd := setup(t, true /* ignoreLogErrors */, &overrides{notificationEnqueuer: notifEnq})
1815+
1816+
templateAdmin := dbgen.User(t, db, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}})
1817+
_ /* other template admin, should not receive notification */ = dbgen.User(t, db, database.User{RBACRoles: []string{codersdk.RoleTemplateAdmin}})
1818+
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: templateAdmin.ID, OrganizationID: pd.OrganizationID})
1819+
user := dbgen.User(t, db, database.User{})
1820+
_ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user.ID, OrganizationID: pd.OrganizationID})
1821+
1822+
template := dbgen.Template(t, db, database.Template{
1823+
Name: "template", Provisioner: database.ProvisionerTypeEcho, OrganizationID: pd.OrganizationID,
1824+
})
1825+
workspace := dbgen.Workspace(t, db, database.Workspace{
1826+
TemplateID: template.ID, OwnerID: user.ID, OrganizationID: pd.OrganizationID,
1827+
})
1828+
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
1829+
OrganizationID: pd.OrganizationID, TemplateID: uuid.NullUUID{UUID: template.ID, Valid: true}, JobID: uuid.New(),
1830+
})
1831+
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
1832+
WorkspaceID: workspace.ID, TemplateVersionID: version.ID, InitiatorID: user.ID, Transition: database.WorkspaceTransitionDelete, Reason: database.BuildReasonInitiator,
1833+
})
1834+
job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{
1835+
FileID: dbgen.File(t, db, database.File{CreatedBy: user.ID}).ID,
1836+
Type: database.ProvisionerJobTypeWorkspaceBuild,
1837+
Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{WorkspaceBuildID: build.ID})),
1838+
OrganizationID: pd.OrganizationID,
1839+
})
1840+
_, err := db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{
1841+
OrganizationID: pd.OrganizationID,
1842+
WorkerID: uuid.NullUUID{UUID: pd.ID, Valid: true},
1843+
Types: []database.ProvisionerType{database.ProvisionerTypeEcho},
1844+
})
1845+
require.NoError(t, err)
1846+
1847+
// when
1848+
_, err = srv.FailJob(ctx, &proto.FailedJob{
1849+
JobId: job.ID.String(), Type: &proto.FailedJob_WorkspaceBuild_{WorkspaceBuild: &proto.FailedJob_WorkspaceBuild{State: []byte{}}},
1850+
})
1851+
require.NoError(t, err)
1852+
1853+
// then
1854+
require.Len(t, notifEnq.Sent, 1)
1855+
assert.Equal(t, notifEnq.Sent[0].UserID, templateAdmin.ID)
1856+
assert.Equal(t, notifEnq.Sent[0].TemplateID, notifications.TemplateWorkspaceManualBuildFailed)
1857+
assert.Contains(t, notifEnq.Sent[0].Targets, template.ID)
1858+
assert.Contains(t, notifEnq.Sent[0].Targets, workspace.ID)
1859+
assert.Contains(t, notifEnq.Sent[0].Targets, workspace.OrganizationID)
1860+
assert.Contains(t, notifEnq.Sent[0].Targets, user.ID)
1861+
assert.Equal(t, workspace.Name, notifEnq.Sent[0].Labels["name"])
1862+
assert.Equal(t, template.Name, notifEnq.Sent[0].Labels["template_name"])
1863+
assert.Equal(t, version.Name, notifEnq.Sent[0].Labels["template_version_name"])
1864+
assert.Equal(t, user.Username, notifEnq.Sent[0].Labels["initiator"])
1865+
assert.Equal(t, user.Username, notifEnq.Sent[0].Labels["workspace_owner_username"])
1866+
assert.Equal(t, strconv.Itoa(int(build.BuildNumber)), notifEnq.Sent[0].Labels["workspace_build_number"])
1867+
})
18071868
}
18081869

18091870
type overrides struct {

0 commit comments

Comments
 (0)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy