feat: notify owner about failed autobuild (#13891)

This commit is contained in:
Marcin Tojek
2024-07-16 10:48:17 +02:00
committed by GitHub
parent 36454aa81b
commit a5e4bf38fe
8 changed files with 204 additions and 25 deletions

View File

@ -0,0 +1 @@
DELETE FROM notification_templates WHERE id = '381df2a9-c0c0-4749-420f-80a9280c66f9';

View File

@ -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);

View File

@ -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.

View File

@ -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")
)

View File

@ -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

View File

@ -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 {

View File

@ -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,

View File

@ -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 {