mirror of
https://github.com/coder/coder.git
synced 2025-07-13 21:36:50 +00:00
feat: notify owner about failed autobuild (#13891)
This commit is contained in:
@ -0,0 +1 @@
|
||||
DELETE FROM notification_templates WHERE id = '381df2a9-c0c0-4749-420f-80a9280c66f9';
|
@ -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);
|
@ -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.
|
||||
|
@ -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")
|
||||
)
|
||||
|
@ -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
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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 {
|
||||
|
Reference in New Issue
Block a user