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
);
5 changes: 5 additions & 0 deletions coderd/notifications/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ var (
TemplateUserAccountCreated = uuid.MustParse("4e19c0ac-94e1-4532-9515-d1801aa283b2")
TemplateUserAccountDeleted = uuid.MustParse("f44d9314-ad03-4bc8-95d0-5cad491da6b6")
)

// Template-related events.
var (
TemplateTemplateManualBuildFailed = uuid.MustParse("48a9d2b9-3655-430c-a31a-2442479e7519")
)
84 changes: 75 additions & 9 deletions coderd/provisionerdserver/provisionerdserver.go
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,25 @@
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 @@
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 @@
// 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 autobuild", slog.F("template_id", template.ID), slog.F("workspace_id", workspace.ID), slog.Error(err))
}
}

Expand Down Expand Up @@ -1585,6 +1615,42 @@
}
}

func (s *server) notifyTemplateOwnerAboutManualBuildFailure(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) {

Check failure on line 1618 in coderd/provisionerdserver/provisionerdserver.go

View workflow job for this annotation

GitHub Actions / lint

func `(*server).notifyTemplateOwnerAboutManualBuildFailure` is unused (unused)
var reason string
initiator := build.InitiatorByUsername
if build.Reason.Valid() {
switch build.Reason {
case database.BuildReasonInitiator:
if build.InitiatorID == workspace.OwnerID {
// Deletions initiated by self should not notify.
return
}

reason = "initiated by user"
case database.BuildReasonAutodelete:
reason = "autodeleted due to dormancy"
initiator = "autobuild"
default:
reason = string(build.Reason)
}
} else {
reason = string(build.Reason)
s.Logger.Warn(ctx, "invalid build reason when sending deletion notification",
slog.F("reason", reason), slog.F("workspace_id", workspace.ID), slog.F("build_id", build.ID))
}

if _, err := s.NotificationsEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceDeleted,
map[string]string{
"name": workspace.Name,
"reason": reason,
"initiator": initiator,
}, "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 workspace deletion", slog.Error(err))
}
}
func (s *server) startTrace(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return s.Tracer.Start(ctx, name, append(opts, trace.WithAttributes(
semconv.ServiceNameKey.String("coderd.provisionerd"),
Expand Down
170 changes: 166 additions & 4 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,7 +1695,7 @@ 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 {
Expand All @@ -1705,12 +1705,12 @@ func TestNotifications(t *testing.T) {
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 +1804,168 @@ func TestNotifications(t *testing.T) {
})
}
})

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

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

ctx := context.Background()
notifEnq := &testutil.FakeNotificationsEnqueuer{}

// 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,
})

initiator := dbgen.User(t, db, database.User{})
templateOwner := dbgen.User(t, db, database.User{})

template := dbgen.Template(t, db, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
OrganizationID: pd.OrganizationID,
CreatedBy: templateOwner.ID,
})
template, err := db.GetTemplateByID(ctx, template.ID)
require.NoError(t, err)
file := dbgen.File(t, db, database.File{CreatedBy: initiator.ID})
workspace := dbgen.Workspace(t, db, database.Workspace{
TemplateID: template.ID,
OwnerID: initiator.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: database.BuildReasonInitiator,
})
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)

require.Len(t, notifEnq.Sent, 1)
require.Equal(t, notifEnq.Sent[0].UserID, templateOwner.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, templateOwner.ID)
require.Contains(t, notifEnq.Sent[0].Targets, initiator.ID)
})

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

ctx := context.Background()
notifEnq := &testutil.FakeNotificationsEnqueuer{}

// 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,
})

initiator := dbgen.User(t, db, database.User{})
templateOwner := initiator

template := dbgen.Template(t, db, database.Template{
Name: "template",
Provisioner: database.ProvisionerTypeEcho,
OrganizationID: pd.OrganizationID,
CreatedBy: templateOwner.ID,
})
template, err := db.GetTemplateByID(ctx, template.ID)
require.NoError(t, err)
file := dbgen.File(t, db, database.File{CreatedBy: initiator.ID})
workspace := dbgen.Workspace(t, db, database.Workspace{
TemplateID: template.ID,
OwnerID: initiator.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: database.BuildReasonInitiator,
})
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)

require.Len(t, notifEnq.Sent, 0)
})
})
}

type overrides struct {
Expand Down
Loading