Skip to content

feat: notify template owner on manual build failures #14262

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 8 commits into from
Closed
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
INSERT INTO
notification_templates (
id,
name,
title_template,
body_template,
"group",
actions
)
VALUES (
'48a9d2b9-3655-430c-a31a-2442479e7519',
'Template Manual Build Failure',
E'Workspace with template "{{.Labels.name}}" failed to build',
E'Hi {{.UserName}}\n\nThe workspace **{{.Labels.workspaceName}}**, using the template **{{.Labels.name}}**, failed during a manual build({{.Labels.transition}}) initiated by the user **{{.Labels.initiator}}**.',
'Template Events',
'[
{
"label": "View build",
"url": "{{ base_url }}/@{{.Labels.workspaceUserName}}/{{.Labels.workspaceName}}/builds/{{.Labels.buildNumber}}"
},
{
"label": "View template",
"url": "{{ base_url }}/templates/{{.Labels.name}}"
}
]'::jsonb
);
3 changes: 2 additions & 1 deletion coderd/notifications/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,6 @@ var (

// Template-related events.
var (
TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be")
TemplateTemplateManualBuildFailed = uuid.MustParse("48a9d2b9-3655-430c-a31a-2442479e7519")
TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be")
)
48 changes: 39 additions & 9 deletions coderd/provisionerdserver/provisionerdserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,25 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto.
return nil, err
}

s.notifyWorkspaceBuildFailed(ctx, workspace, build)
manualBuild := build.Reason.Valid() && build.Reason == database.BuildReasonInitiator
if !manualBuild {
s.notifyWorkspaceAutoBuildFailed(ctx, workspace, build)
} else {
template, err := s.Database.GetTemplateByID(ctx, workspace.TemplateID)
if err != nil {
return nil, xerrors.Errorf("get template to notify manual build failed: %w", err)
}

owner, err := s.Database.GetUserByID(ctx, workspace.OwnerID)
if err != nil {
return nil, xerrors.Errorf("get owner to notify manual build failed: %w", err)
}

// Only notify the template creator if the build was initiated by someone else
if build.InitiatorID != template.CreatedBy {
s.notifyTemplateManualBuildFailed(ctx, workspace, owner, template, build)
}
}

err = s.Pubsub.Publish(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), []byte{})
if err != nil {
Expand Down Expand Up @@ -1095,13 +1113,8 @@ 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)

func (s *server) notifyWorkspaceAutoBuildFailed(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) {
reason := string(build.Reason)
if _, err := s.NotificationsEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceAutobuildFailed,
map[string]string{
"name": workspace.Name,
Expand All @@ -1110,7 +1123,24 @@ func (s *server) notifyWorkspaceBuildFailed(ctx context.Context, workspace datab
// 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))
s.Logger.Warn(ctx, "failed to notify of failed workspace autobuild", slog.F("workspace_id", workspace.ID), slog.Error(err))
}
}

func (s *server) notifyTemplateManualBuildFailed(ctx context.Context, workspace database.Workspace, owner database.User, template database.Template, build database.WorkspaceBuild) {
if _, err := s.NotificationsEnqueuer.Enqueue(ctx, template.CreatedBy, notifications.TemplateTemplateManualBuildFailed,
map[string]string{
"name": template.Name,
"workspaceName": workspace.Name,
"transition": string(build.Transition),
"initiator": build.InitiatorByUsername,
"workspaceUserName": owner.Username,
"buildNumber": strconv.FormatInt(int64(build.BuildNumber), 10),
}, "provisionerdserver",
// Associate this notification with all the related entities.
workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID, template.CreatedBy,
); err != nil {
s.Logger.Warn(ctx, "failed to notify of failed template manual build", slog.F("template_id", template.ID), slog.F("workspace_id", workspace.ID), slog.Error(err))
}
}

Expand Down
126 changes: 120 additions & 6 deletions coderd/provisionerdserver/provisionerdserver_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1568,7 +1568,7 @@ func TestInsertWorkspaceResource(t *testing.T) {
func TestNotifications(t *testing.T) {
t.Parallel()

t.Run("Workspace deletion", func(t *testing.T) {
t.Run("WorkspaceDeletion", func(t *testing.T) {
t.Parallel()

tests := []struct {
Expand Down Expand Up @@ -1695,22 +1695,21 @@ func TestNotifications(t *testing.T) {
}
})

t.Run("Workspace build failed", func(t *testing.T) {
t.Run("WorkspaceAutoBuildFailed", func(t *testing.T) {
t.Parallel()

tests := []struct {
name string

name string
buildReason database.BuildReason
shouldNotify bool
}{
{
name: "initiated by owner",
name: "InitiatedByOwner",
buildReason: database.BuildReasonInitiator,
shouldNotify: false,
},
{
name: "initiated by autostart",
name: "InitiatedByAutoStart",
buildReason: database.BuildReasonAutostart,
shouldNotify: true,
},
Expand Down Expand Up @@ -1804,6 +1803,121 @@ func TestNotifications(t *testing.T) {
})
}
})

t.Run("TemplateManualBuildFailed", func(t *testing.T) {
t.Parallel()

var (
ctx = context.Background()
notifEnq = &testutil.FakeNotificationsEnqueuer{}
// To avoid spamming the output, ignore log errors. This test is
// designed to check a build failure, which is expected to log errors.
ignoreLogErrors = true
srv, db, ps, pd = setup(t, ignoreLogErrors, &overrides{
notificationEnqueuer: notifEnq,
})
userA = dbgen.User(t, db, database.User{})
userB = dbgen.User(t, db, database.User{})
)

tc := []struct {
name string
owner database.User
initiator database.User
shouldNotify bool
}{
{
name: "InitiatedByOwner",
owner: userA,
initiator: userA,
shouldNotify: false,
},
{
name: "InitiatedBySomeoneElse",
owner: userB,
initiator: userA,
shouldNotify: true,
},
}

for _, c := range tc {
t.Run(c.name, func(t *testing.T) {
// Given: a template created by the owner
template := dbgen.Template(t, db, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
OrganizationID: pd.OrganizationID,
CreatedBy: c.owner.ID,
})
template, err := db.GetTemplateByID(ctx, template.ID)
require.NoError(t, err)
version := dbgen.TemplateVersion(t, db, database.TemplateVersion{
OrganizationID: pd.OrganizationID,
TemplateID: uuid.NullUUID{
UUID: template.ID,
Valid: true,
},
JobID: uuid.New(),
})

// And: a workspace build initiated manually by a user
workspace := dbgen.Workspace(t, db, database.Workspace{
TemplateID: template.ID,
OwnerID: c.initiator.ID,
OrganizationID: pd.OrganizationID,
})
build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{
WorkspaceID: workspace.ID,
TemplateVersionID: version.ID,
InitiatorID: c.initiator.ID,
Transition: database.WorkspaceTransitionDelete,
Reason: database.BuildReasonInitiator,
})
file := dbgen.File(t, db, database.File{CreatedBy: c.initiator.ID})
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)

// When: the workspace build job fails
_, err = srv.FailJob(ctx, &proto.FailedJob{
JobId: job.ID.String(),
Type: &proto.FailedJob_WorkspaceBuild_{
WorkspaceBuild: &proto.FailedJob_WorkspaceBuild{
State: []byte{},
},
},
})
require.NoError(t, err)

// Then: send the appropriate notifications
if c.shouldNotify {
require.Len(t, notifEnq.Sent, 1)
require.Equal(t, notifEnq.Sent[0].UserID, c.owner.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, c.owner.ID)
require.Contains(t, notifEnq.Sent[0].Targets, c.initiator.ID)
} else {
require.Len(t, notifEnq.Sent, 0)
}
})
}
})
}

type overrides struct {
Expand Down
Loading