Skip to content

Commit 47f2c7d

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

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)