diff --git a/cli/server.go b/cli/server.go index 8336fba770ebc..5988c0e1e9187 100644 --- a/cli/server.go +++ b/cli/server.go @@ -56,6 +56,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/v2/coderd/entitlements" + "github.com/coder/coder/v2/coderd/notifications/reports" "github.com/coder/coder/v2/coderd/runtimeconfig" "github.com/coder/pretty" "github.com/coder/quartz" @@ -1018,6 +1019,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // nolint:gocritic // TODO: create own role. notificationsManager.Run(dbauthz.AsSystemRestricted(ctx)) + + // Run report generator to distribute periodic reports. + notificationReportGenerator := reports.NewReportGenerator(ctx, logger, options.Database, options.NotificationsEnqueuer, quartz.NewReal()) + defer notificationReportGenerator.Close() } // Wrap the server in middleware that redirects to the access URL if diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 0fb5001b9a075..001c0e26a2f61 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1459,6 +1459,13 @@ func (q *querier) GetExternalAuthLinksByUserID(ctx context.Context, userID uuid. return fetchWithPostFilter(q.auth, policy.ActionReadPersonal, q.db.GetExternalAuthLinksByUserID)(ctx, userID) } +func (q *querier) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg database.GetFailedWorkspaceBuildsByTemplateIDParams) ([]database.GetFailedWorkspaceBuildsByTemplateIDRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetFailedWorkspaceBuildsByTemplateID(ctx, arg) +} + func (q *querier) GetFileByHashAndCreator(ctx context.Context, arg database.GetFileByHashAndCreatorParams) (database.File, error) { file, err := q.db.GetFileByHashAndCreator(ctx, arg) if err != nil { @@ -1628,6 +1635,13 @@ func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg datab return q.db.GetNotificationMessagesByStatus(ctx, arg) } +func (q *querier) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, arg uuid.UUID) (database.NotificationReportGeneratorLog, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return database.NotificationReportGeneratorLog{}, err + } + return q.db.GetNotificationReportGeneratorLogByTemplate(ctx, arg) +} + func (q *querier) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (database.NotificationTemplate, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceNotificationTemplate); err != nil { return database.NotificationTemplate{}, err @@ -2510,6 +2524,13 @@ func (q *querier) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuil return q.db.GetWorkspaceBuildParameters(ctx, workspaceBuildID) } +func (q *querier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetWorkspaceBuildStatsByTemplates(ctx, since) +} + func (q *querier) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg database.GetWorkspaceBuildsByWorkspaceIDParams) ([]database.WorkspaceBuild, error) { if _, err := q.GetWorkspaceByID(ctx, arg.WorkspaceID); err != nil { return nil, err @@ -3966,6 +3987,13 @@ func (q *querier) UpsertLogoURL(ctx context.Context, value string) error { return q.db.UpsertLogoURL(ctx, value) } +func (q *querier) UpsertNotificationReportGeneratorLog(ctx context.Context, arg database.UpsertNotificationReportGeneratorLogParams) error { + if err := q.authorizeContext(ctx, policy.ActionCreate, rbac.ResourceSystem); err != nil { + return err + } + return q.db.UpsertNotificationReportGeneratorLog(ctx, arg) +} + func (q *querier) UpsertNotificationsSettings(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 8cdcf4fef1ab8..fdc3b6612a09e 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2819,6 +2819,28 @@ func (s *MethodTestSuite) TestSystemFunctions() { Value: "value", }).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) + s.Run("GetFailedWorkspaceBuildsByTemplateID", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.GetFailedWorkspaceBuildsByTemplateIDParams{ + TemplateID: uuid.New(), + Since: dbtime.Now(), + }).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetNotificationReportGeneratorLogByTemplate", s.Subtest(func(db database.Store, check *expects) { + _ = db.UpsertNotificationReportGeneratorLog(context.Background(), database.UpsertNotificationReportGeneratorLogParams{ + NotificationTemplateID: notifications.TemplateWorkspaceBuildsFailedReport, + LastGeneratedAt: dbtime.Now(), + }) + check.Args(notifications.TemplateWorkspaceBuildsFailedReport).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("GetWorkspaceBuildStatsByTemplates", s.Subtest(func(db database.Store, check *expects) { + check.Args(dbtime.Now()).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) + s.Run("UpsertNotificationReportGeneratorLog", s.Subtest(func(db database.Store, check *expects) { + check.Args(database.UpsertNotificationReportGeneratorLogParams{ + NotificationTemplateID: uuid.New(), + LastGeneratedAt: dbtime.Now(), + }).Asserts(rbac.ResourceSystem, policy.ActionCreate) + })) } func (s *MethodTestSuite) TestNotifications() { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 64478065bc408..dff78999c9211 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -187,53 +187,54 @@ type data struct { userLinks []database.UserLink // New tables - workspaceAgentStats []database.WorkspaceAgentStat - auditLogs []database.AuditLog - cryptoKeys []database.CryptoKey - dbcryptKeys []database.DBCryptKey - files []database.File - externalAuthLinks []database.ExternalAuthLink - gitSSHKey []database.GitSSHKey - groupMembers []database.GroupMemberTable - groups []database.Group - jfrogXRayScans []database.JfrogXrayScan - licenses []database.License - notificationMessages []database.NotificationMessage - notificationPreferences []database.NotificationPreference - oauth2ProviderApps []database.OAuth2ProviderApp - oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret - oauth2ProviderAppCodes []database.OAuth2ProviderAppCode - oauth2ProviderAppTokens []database.OAuth2ProviderAppToken - parameterSchemas []database.ParameterSchema - provisionerDaemons []database.ProvisionerDaemon - provisionerJobLogs []database.ProvisionerJobLog - provisionerJobs []database.ProvisionerJob - provisionerKeys []database.ProvisionerKey - replicas []database.Replica - templateVersions []database.TemplateVersionTable - templateVersionParameters []database.TemplateVersionParameter - templateVersionVariables []database.TemplateVersionVariable - templateVersionWorkspaceTags []database.TemplateVersionWorkspaceTag - templates []database.TemplateTable - templateUsageStats []database.TemplateUsageStat - workspaceAgents []database.WorkspaceAgent - workspaceAgentMetadata []database.WorkspaceAgentMetadatum - workspaceAgentLogs []database.WorkspaceAgentLog - workspaceAgentLogSources []database.WorkspaceAgentLogSource - workspaceAgentScripts []database.WorkspaceAgentScript - workspaceAgentPortShares []database.WorkspaceAgentPortShare - workspaceApps []database.WorkspaceApp - workspaceAppStatsLastInsertID int64 - workspaceAppStats []database.WorkspaceAppStat - workspaceBuilds []database.WorkspaceBuild - workspaceBuildParameters []database.WorkspaceBuildParameter - workspaceResourceMetadata []database.WorkspaceResourceMetadatum - workspaceResources []database.WorkspaceResource - workspaces []database.Workspace - workspaceProxies []database.WorkspaceProxy - customRoles []database.CustomRole - provisionerJobTimings []database.ProvisionerJobTiming - runtimeConfig map[string]string + auditLogs []database.AuditLog + cryptoKeys []database.CryptoKey + dbcryptKeys []database.DBCryptKey + files []database.File + externalAuthLinks []database.ExternalAuthLink + gitSSHKey []database.GitSSHKey + groupMembers []database.GroupMemberTable + groups []database.Group + jfrogXRayScans []database.JfrogXrayScan + licenses []database.License + notificationMessages []database.NotificationMessage + notificationPreferences []database.NotificationPreference + notificationReportGeneratorLogs []database.NotificationReportGeneratorLog + oauth2ProviderApps []database.OAuth2ProviderApp + oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret + oauth2ProviderAppCodes []database.OAuth2ProviderAppCode + oauth2ProviderAppTokens []database.OAuth2ProviderAppToken + parameterSchemas []database.ParameterSchema + provisionerDaemons []database.ProvisionerDaemon + provisionerJobLogs []database.ProvisionerJobLog + provisionerJobs []database.ProvisionerJob + provisionerKeys []database.ProvisionerKey + replicas []database.Replica + templateVersions []database.TemplateVersionTable + templateVersionParameters []database.TemplateVersionParameter + templateVersionVariables []database.TemplateVersionVariable + templateVersionWorkspaceTags []database.TemplateVersionWorkspaceTag + templates []database.TemplateTable + templateUsageStats []database.TemplateUsageStat + workspaceAgents []database.WorkspaceAgent + workspaceAgentMetadata []database.WorkspaceAgentMetadatum + workspaceAgentLogs []database.WorkspaceAgentLog + workspaceAgentLogSources []database.WorkspaceAgentLogSource + workspaceAgentPortShares []database.WorkspaceAgentPortShare + workspaceAgentScripts []database.WorkspaceAgentScript + workspaceAgentStats []database.WorkspaceAgentStat + workspaceApps []database.WorkspaceApp + workspaceAppStatsLastInsertID int64 + workspaceAppStats []database.WorkspaceAppStat + workspaceBuilds []database.WorkspaceBuild + workspaceBuildParameters []database.WorkspaceBuildParameter + workspaceResourceMetadata []database.WorkspaceResourceMetadatum + workspaceResources []database.WorkspaceResource + workspaces []database.Workspace + workspaceProxies []database.WorkspaceProxy + customRoles []database.CustomRole + provisionerJobTimings []database.ProvisionerJobTiming + runtimeConfig map[string]string // Locks is a map of lock names. Any keys within the map are currently // locked. locks map[int64]struct{} @@ -2621,6 +2622,75 @@ func (q *FakeQuerier) GetExternalAuthLinksByUserID(_ context.Context, userID uui return gals, nil } +func (q *FakeQuerier) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg database.GetFailedWorkspaceBuildsByTemplateIDParams) ([]database.GetFailedWorkspaceBuildsByTemplateIDRow, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + workspaceBuildStats := []database.GetFailedWorkspaceBuildsByTemplateIDRow{} + for _, wb := range q.workspaceBuilds { + job, err := q.getProvisionerJobByIDNoLock(ctx, wb.JobID) + if err != nil { + return nil, xerrors.Errorf("get provisioner job by ID: %w", err) + } + + if job.JobStatus != database.ProvisionerJobStatusFailed { + continue + } + + if !job.CompletedAt.Valid { + continue + } + + if wb.CreatedAt.Before(arg.Since) { + continue + } + + w, err := q.getWorkspaceByIDNoLock(ctx, wb.WorkspaceID) + if err != nil { + return nil, xerrors.Errorf("get workspace by ID: %w", err) + } + + t, err := q.getTemplateByIDNoLock(ctx, w.TemplateID) + if err != nil { + return nil, xerrors.Errorf("get template by ID: %w", err) + } + + if t.ID != arg.TemplateID { + continue + } + + workspaceOwner, err := q.getUserByIDNoLock(w.OwnerID) + if err != nil { + return nil, xerrors.Errorf("get user by ID: %w", err) + } + + templateVersion, err := q.getTemplateVersionByIDNoLock(ctx, wb.TemplateVersionID) + if err != nil { + return nil, xerrors.Errorf("get template version by ID: %w", err) + } + + workspaceBuildStats = append(workspaceBuildStats, database.GetFailedWorkspaceBuildsByTemplateIDRow{ + WorkspaceName: w.Name, + WorkspaceOwnerUsername: workspaceOwner.Username, + TemplateVersionName: templateVersion.Name, + WorkspaceBuildNumber: wb.BuildNumber, + }) + } + + sort.Slice(workspaceBuildStats, func(i, j int) bool { + if workspaceBuildStats[i].TemplateVersionName != workspaceBuildStats[j].TemplateVersionName { + return workspaceBuildStats[i].TemplateVersionName < workspaceBuildStats[j].TemplateVersionName + } + return workspaceBuildStats[i].WorkspaceBuildNumber > workspaceBuildStats[j].WorkspaceBuildNumber + }) + return workspaceBuildStats, nil +} + func (q *FakeQuerier) GetFileByHashAndCreator(_ context.Context, arg database.GetFileByHashAndCreatorParams) (database.File, error) { if err := validateDatabaseType(arg); err != nil { return database.File{}, err @@ -3044,6 +3114,23 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat return out, nil } +func (q *FakeQuerier) GetNotificationReportGeneratorLogByTemplate(_ context.Context, templateID uuid.UUID) (database.NotificationReportGeneratorLog, error) { + err := validateDatabaseType(templateID) + if err != nil { + return database.NotificationReportGeneratorLog{}, err + } + + q.mutex.RLock() + defer q.mutex.RUnlock() + + for _, record := range q.notificationReportGeneratorLogs { + if record.NotificationTemplateID == templateID { + return record, nil + } + } + return database.NotificationReportGeneratorLog{}, sql.ErrNoRows +} + func (*FakeQuerier) GetNotificationTemplateByID(_ context.Context, _ uuid.UUID) (database.NotificationTemplate, error) { // Not implementing this function because it relies on state in the database which is created with migrations. // We could consider using code-generation to align the database state and dbmem, but it's not worth it right now. @@ -5964,6 +6051,63 @@ func (q *FakeQuerier) GetWorkspaceBuildParameters(_ context.Context, workspaceBu return params, nil } +func (q *FakeQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + templateStats := map[uuid.UUID]database.GetWorkspaceBuildStatsByTemplatesRow{} + for _, wb := range q.workspaceBuilds { + job, err := q.getProvisionerJobByIDNoLock(ctx, wb.JobID) + if err != nil { + return nil, xerrors.Errorf("get provisioner job by ID: %w", err) + } + + if !job.CompletedAt.Valid { + continue + } + + if wb.CreatedAt.Before(since) { + continue + } + + w, err := q.getWorkspaceByIDNoLock(ctx, wb.WorkspaceID) + if err != nil { + return nil, xerrors.Errorf("get workspace by ID: %w", err) + } + + if _, ok := templateStats[w.TemplateID]; !ok { + t, err := q.getTemplateByIDNoLock(ctx, w.TemplateID) + if err != nil { + return nil, xerrors.Errorf("get template by ID: %w", err) + } + + templateStats[w.TemplateID] = database.GetWorkspaceBuildStatsByTemplatesRow{ + TemplateID: w.TemplateID, + TemplateName: t.Name, + TemplateDisplayName: t.DisplayName, + TemplateOrganizationID: w.OrganizationID, + } + } + + s := templateStats[w.TemplateID] + s.TotalBuilds++ + if job.JobStatus == database.ProvisionerJobStatusFailed { + s.FailedBuilds++ + } + templateStats[w.TemplateID] = s + } + + rows := make([]database.GetWorkspaceBuildStatsByTemplatesRow, 0, len(templateStats)) + for _, ts := range templateStats { + rows = append(rows, ts) + } + + sort.Slice(rows, func(i, j int) bool { + return rows[i].TemplateName < rows[j].TemplateName + }) + return rows, nil +} + func (q *FakeQuerier) GetWorkspaceBuildsByWorkspaceID(_ context.Context, params database.GetWorkspaceBuildsByWorkspaceIDParams, ) ([]database.WorkspaceBuild, error) { @@ -9440,6 +9584,26 @@ func (q *FakeQuerier) UpsertLogoURL(_ context.Context, data string) error { return nil } +func (q *FakeQuerier) UpsertNotificationReportGeneratorLog(_ context.Context, arg database.UpsertNotificationReportGeneratorLogParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, record := range q.notificationReportGeneratorLogs { + if arg.NotificationTemplateID == record.NotificationTemplateID { + q.notificationReportGeneratorLogs[i].LastGeneratedAt = arg.LastGeneratedAt + return nil + } + } + + q.notificationReportGeneratorLogs = append(q.notificationReportGeneratorLogs, database.NotificationReportGeneratorLog(arg)) + return nil +} + func (q *FakeQuerier) UpsertNotificationsSettings(_ context.Context, data string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index b6d77700eccc2..e0b48961678b7 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -634,6 +634,13 @@ func (m metricsStore) GetExternalAuthLinksByUserID(ctx context.Context, userID u return r0, r1 } +func (m metricsStore) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg database.GetFailedWorkspaceBuildsByTemplateIDParams) ([]database.GetFailedWorkspaceBuildsByTemplateIDRow, error) { + start := time.Now() + r0, r1 := m.s.GetFailedWorkspaceBuildsByTemplateID(ctx, arg) + m.queryLatencies.WithLabelValues("GetFailedWorkspaceBuildsByTemplateID").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetFileByHashAndCreator(ctx context.Context, arg database.GetFileByHashAndCreatorParams) (database.File, error) { start := time.Now() file, err := m.s.GetFileByHashAndCreator(ctx, arg) @@ -788,6 +795,13 @@ func (m metricsStore) GetNotificationMessagesByStatus(ctx context.Context, arg d return r0, r1 } +func (m metricsStore) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, arg uuid.UUID) (database.NotificationReportGeneratorLog, error) { + start := time.Now() + r0, r1 := m.s.GetNotificationReportGeneratorLogByTemplate(ctx, arg) + m.queryLatencies.WithLabelValues("GetNotificationReportGeneratorLogByTemplate").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (database.NotificationTemplate, error) { start := time.Now() r0, r1 := m.s.GetNotificationTemplateByID(ctx, id) @@ -1474,6 +1488,13 @@ func (m metricsStore) GetWorkspaceBuildParameters(ctx context.Context, workspace return params, err } +func (m metricsStore) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { + start := time.Now() + r0, r1 := m.s.GetWorkspaceBuildStatsByTemplates(ctx, since) + m.queryLatencies.WithLabelValues("GetWorkspaceBuildStatsByTemplates").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg database.GetWorkspaceBuildsByWorkspaceIDParams) ([]database.WorkspaceBuild, error) { start := time.Now() builds, err := m.s.GetWorkspaceBuildsByWorkspaceID(ctx, arg) @@ -2517,6 +2538,13 @@ func (m metricsStore) UpsertLogoURL(ctx context.Context, value string) error { return r0 } +func (m metricsStore) UpsertNotificationReportGeneratorLog(ctx context.Context, arg database.UpsertNotificationReportGeneratorLogParams) error { + start := time.Now() + r0 := m.s.UpsertNotificationReportGeneratorLog(ctx, arg) + m.queryLatencies.WithLabelValues("UpsertNotificationReportGeneratorLog").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpsertNotificationsSettings(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertNotificationsSettings(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 9608865d2844e..2942f10767f50 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1253,6 +1253,21 @@ func (mr *MockStoreMockRecorder) GetExternalAuthLinksByUserID(arg0, arg1 any) *g return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetExternalAuthLinksByUserID", reflect.TypeOf((*MockStore)(nil).GetExternalAuthLinksByUserID), arg0, arg1) } +// GetFailedWorkspaceBuildsByTemplateID mocks base method. +func (m *MockStore) GetFailedWorkspaceBuildsByTemplateID(arg0 context.Context, arg1 database.GetFailedWorkspaceBuildsByTemplateIDParams) ([]database.GetFailedWorkspaceBuildsByTemplateIDRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetFailedWorkspaceBuildsByTemplateID", arg0, arg1) + ret0, _ := ret[0].([]database.GetFailedWorkspaceBuildsByTemplateIDRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetFailedWorkspaceBuildsByTemplateID indicates an expected call of GetFailedWorkspaceBuildsByTemplateID. +func (mr *MockStoreMockRecorder) GetFailedWorkspaceBuildsByTemplateID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetFailedWorkspaceBuildsByTemplateID", reflect.TypeOf((*MockStore)(nil).GetFailedWorkspaceBuildsByTemplateID), arg0, arg1) +} + // GetFileByHashAndCreator mocks base method. func (m *MockStore) GetFileByHashAndCreator(arg0 context.Context, arg1 database.GetFileByHashAndCreatorParams) (database.File, error) { m.ctrl.T.Helper() @@ -1583,6 +1598,21 @@ func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), arg0, arg1) } +// GetNotificationReportGeneratorLogByTemplate mocks base method. +func (m *MockStore) GetNotificationReportGeneratorLogByTemplate(arg0 context.Context, arg1 uuid.UUID) (database.NotificationReportGeneratorLog, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationReportGeneratorLogByTemplate", arg0, arg1) + ret0, _ := ret[0].(database.NotificationReportGeneratorLog) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationReportGeneratorLogByTemplate indicates an expected call of GetNotificationReportGeneratorLogByTemplate. +func (mr *MockStoreMockRecorder) GetNotificationReportGeneratorLogByTemplate(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationReportGeneratorLogByTemplate", reflect.TypeOf((*MockStore)(nil).GetNotificationReportGeneratorLogByTemplate), arg0, arg1) +} + // GetNotificationTemplateByID mocks base method. func (m *MockStore) GetNotificationTemplateByID(arg0 context.Context, arg1 uuid.UUID) (database.NotificationTemplate, error) { m.ctrl.T.Helper() @@ -3083,6 +3113,21 @@ func (mr *MockStoreMockRecorder) GetWorkspaceBuildParameters(arg0, arg1 any) *go return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildParameters", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildParameters), arg0, arg1) } +// GetWorkspaceBuildStatsByTemplates mocks base method. +func (m *MockStore) GetWorkspaceBuildStatsByTemplates(arg0 context.Context, arg1 time.Time) ([]database.GetWorkspaceBuildStatsByTemplatesRow, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetWorkspaceBuildStatsByTemplates", arg0, arg1) + ret0, _ := ret[0].([]database.GetWorkspaceBuildStatsByTemplatesRow) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetWorkspaceBuildStatsByTemplates indicates an expected call of GetWorkspaceBuildStatsByTemplates. +func (mr *MockStoreMockRecorder) GetWorkspaceBuildStatsByTemplates(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetWorkspaceBuildStatsByTemplates", reflect.TypeOf((*MockStore)(nil).GetWorkspaceBuildStatsByTemplates), arg0, arg1) +} + // GetWorkspaceBuildsByWorkspaceID mocks base method. func (m *MockStore) GetWorkspaceBuildsByWorkspaceID(arg0 context.Context, arg1 database.GetWorkspaceBuildsByWorkspaceIDParams) ([]database.WorkspaceBuild, error) { m.ctrl.T.Helper() @@ -5287,6 +5332,20 @@ func (mr *MockStoreMockRecorder) UpsertLogoURL(arg0, arg1 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertLogoURL", reflect.TypeOf((*MockStore)(nil).UpsertLogoURL), arg0, arg1) } +// UpsertNotificationReportGeneratorLog mocks base method. +func (m *MockStore) UpsertNotificationReportGeneratorLog(arg0 context.Context, arg1 database.UpsertNotificationReportGeneratorLogParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertNotificationReportGeneratorLog", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertNotificationReportGeneratorLog indicates an expected call of UpsertNotificationReportGeneratorLog. +func (mr *MockStoreMockRecorder) UpsertNotificationReportGeneratorLog(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationReportGeneratorLog", reflect.TypeOf((*MockStore)(nil).UpsertNotificationReportGeneratorLog), arg0, arg1) +} + // UpsertNotificationsSettings mocks base method. func (m *MockStore) UpsertNotificationsSettings(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index 0508ea164620c..cb62b12c11848 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -751,6 +751,13 @@ CREATE TABLE notification_preferences ( updated_at timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL ); +CREATE TABLE notification_report_generator_logs ( + notification_template_id uuid NOT NULL, + last_generated_at timestamp with time zone NOT NULL +); + +COMMENT ON TABLE notification_report_generator_logs IS 'Log of generated reports for users.'; + CREATE TABLE notification_templates ( id uuid NOT NULL, name text NOT NULL, @@ -1726,6 +1733,9 @@ ALTER TABLE ONLY notification_messages ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); +ALTER TABLE ONLY notification_report_generator_logs + ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); + ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); diff --git a/coderd/database/lock.go b/coderd/database/lock.go index b724e9b26dbd9..0ebf6b0f1428e 100644 --- a/coderd/database/lock.go +++ b/coderd/database/lock.go @@ -10,6 +10,7 @@ const ( LockIDEnterpriseDeploymentSetup LockIDDBRollup LockIDDBPurge + LockIDNotificationsReportGenerator ) // GenLockID generates a unique and consistent lock ID from a given string. diff --git a/coderd/database/migrations/000253_email_reports.down.sql b/coderd/database/migrations/000253_email_reports.down.sql new file mode 100644 index 0000000000000..ab45123bcd53b --- /dev/null +++ b/coderd/database/migrations/000253_email_reports.down.sql @@ -0,0 +1,3 @@ +DELETE FROM notification_templates WHERE id = '34a20db2-e9cc-4a93-b0e4-8569699d7a00'; + +DROP TABLE notification_report_generator_logs; diff --git a/coderd/database/migrations/000253_email_reports.up.sql b/coderd/database/migrations/000253_email_reports.up.sql new file mode 100644 index 0000000000000..0d77020451b46 --- /dev/null +++ b/coderd/database/migrations/000253_email_reports.up.sql @@ -0,0 +1,30 @@ +INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions) +VALUES ('34a20db2-e9cc-4a93-b0e4-8569699d7a00', 'Report: Workspace Builds Failed For Template', E'Workspace builds failed for template "{{.Labels.template_display_name}}"', + E'Hi {{.UserName}}, + +Template **{{.Labels.template_display_name}}** has failed to build {{.Data.failed_builds}}/{{.Data.total_builds}} times over the last {{.Data.report_frequency}}. + +**Report:** +{{range $version := .Data.template_versions}} +**{{$version.template_version_name}}** failed {{$version.failed_count}} time{{if gt $version.failed_count 1}}s{{end}}: +{{range $build := $version.failed_builds}} +* [{{$build.workspace_owner_username}} / {{$build.workspace_name}} / #{{$build.build_number}}]({{base_url}}/@{{$build.workspace_owner_username}}/{{$build.workspace_name}}/builds/{{$build.build_number}}) +{{- end}} +{{end}} +We recommend reviewing these issues to ensure future builds are successful.', + 'Template Events', '[ + { + "label": "View workspaces", + "url": "{{ base_url }}/workspaces?filter=template%3A{{.Labels.template_name}}" + } + ]'::jsonb); + +CREATE TABLE notification_report_generator_logs +( + notification_template_id uuid NOT NULL, + last_generated_at timestamp with time zone NOT NULL, + + PRIMARY KEY (notification_template_id) +); + +COMMENT ON TABLE notification_report_generator_logs IS 'Log of generated reports for users.'; diff --git a/coderd/database/migrations/migrate_test.go b/coderd/database/migrations/migrate_test.go index f7e284621edea..51e7fcc86cb03 100644 --- a/coderd/database/migrations/migrate_test.go +++ b/coderd/database/migrations/migrate_test.go @@ -268,6 +268,7 @@ func TestMigrateUpWithFixtures(t *testing.T) { "template_version_variables", "dbcrypt_keys", // having zero rows is a valid state for this table "template_version_workspace_tags", + "notification_report_generator_logs", } s := &tableStats{s: make(map[string]int)} diff --git a/coderd/database/models.go b/coderd/database/models.go index 9c00309b11aa2..47688e57a52cf 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -2262,6 +2262,12 @@ type NotificationPreference struct { UpdatedAt time.Time `db:"updated_at" json:"updated_at"` } +// Log of generated reports for users. +type NotificationReportGeneratorLog struct { + NotificationTemplateID uuid.UUID `db:"notification_template_id" json:"notification_template_id"` + LastGeneratedAt time.Time `db:"last_generated_at" json:"last_generated_at"` +} + // Templates from which to create notification messages. type NotificationTemplate struct { ID uuid.UUID `db:"id" json:"id"` diff --git a/coderd/database/querier.go b/coderd/database/querier.go index efe18dfbcb8e8..1c1a9270fd5c0 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -144,6 +144,7 @@ type sqlcQuerier interface { GetDeploymentWorkspaceStats(ctx context.Context) (GetDeploymentWorkspaceStatsRow, error) GetExternalAuthLink(ctx context.Context, arg GetExternalAuthLinkParams) (ExternalAuthLink, error) GetExternalAuthLinksByUserID(ctx context.Context, userID uuid.UUID) ([]ExternalAuthLink, error) + GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg GetFailedWorkspaceBuildsByTemplateIDParams) ([]GetFailedWorkspaceBuildsByTemplateIDRow, error) GetFileByHashAndCreator(ctx context.Context, arg GetFileByHashAndCreatorParams) (File, error) GetFileByID(ctx context.Context, id uuid.UUID) (File, error) // Get all templates that use a file. @@ -170,6 +171,8 @@ type sqlcQuerier interface { GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error) + // Fetch the notification report generator log indicating recent activity. + GetNotificationReportGeneratorLogByTemplate(ctx context.Context, templateID uuid.UUID) (NotificationReportGeneratorLog, error) GetNotificationTemplateByID(ctx context.Context, id uuid.UUID) (NotificationTemplate, error) GetNotificationTemplatesByKind(ctx context.Context, kind NotificationTemplateKind) ([]NotificationTemplate, error) GetNotificationsSettings(ctx context.Context) (string, error) @@ -307,6 +310,7 @@ type sqlcQuerier interface { GetWorkspaceBuildByJobID(ctx context.Context, jobID uuid.UUID) (WorkspaceBuild, error) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Context, arg GetWorkspaceBuildByWorkspaceIDAndBuildNumberParams) (WorkspaceBuild, error) GetWorkspaceBuildParameters(ctx context.Context, workspaceBuildID uuid.UUID) ([]WorkspaceBuildParameter, error) + GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]GetWorkspaceBuildStatsByTemplatesRow, error) GetWorkspaceBuildsByWorkspaceID(ctx context.Context, arg GetWorkspaceBuildsByWorkspaceIDParams) ([]WorkspaceBuild, error) GetWorkspaceBuildsCreatedAfter(ctx context.Context, createdAt time.Time) ([]WorkspaceBuild, error) GetWorkspaceByAgentID(ctx context.Context, agentID uuid.UUID) (GetWorkspaceByAgentIDRow, error) @@ -489,6 +493,8 @@ type sqlcQuerier interface { UpsertJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg UpsertJFrogXrayScanByWorkspaceAndAgentIDParams) error UpsertLastUpdateCheck(ctx context.Context, value string) error UpsertLogoURL(ctx context.Context, value string) error + // Insert or update notification report generator logs with recent activity. + UpsertNotificationReportGeneratorLog(ctx context.Context, arg UpsertNotificationReportGeneratorLogParams) error UpsertNotificationsSettings(ctx context.Context, value string) error UpsertOAuthSigningKey(ctx context.Context, value string) error UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ba21aaae8aa8a..8bdb0bade2605 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3879,6 +3879,23 @@ func (q *sqlQuerier) GetNotificationMessagesByStatus(ctx context.Context, arg Ge return items, nil } +const getNotificationReportGeneratorLogByTemplate = `-- name: GetNotificationReportGeneratorLogByTemplate :one +SELECT + notification_template_id, last_generated_at +FROM + notification_report_generator_logs +WHERE + notification_template_id = $1::uuid +` + +// Fetch the notification report generator log indicating recent activity. +func (q *sqlQuerier) GetNotificationReportGeneratorLogByTemplate(ctx context.Context, templateID uuid.UUID) (NotificationReportGeneratorLog, error) { + row := q.db.QueryRowContext(ctx, getNotificationReportGeneratorLogByTemplate, templateID) + var i NotificationReportGeneratorLog + err := row.Scan(&i.NotificationTemplateID, &i.LastGeneratedAt) + return i, err +} + const getNotificationTemplateByID = `-- name: GetNotificationTemplateByID :one SELECT id, name, title_template, body_template, actions, "group", method, kind FROM notification_templates @@ -4028,6 +4045,23 @@ func (q *sqlQuerier) UpdateUserNotificationPreferences(ctx context.Context, arg return result.RowsAffected() } +const upsertNotificationReportGeneratorLog = `-- name: UpsertNotificationReportGeneratorLog :exec +INSERT INTO notification_report_generator_logs (notification_template_id, last_generated_at) VALUES ($1, $2) +ON CONFLICT (notification_template_id) DO UPDATE set last_generated_at = EXCLUDED.last_generated_at +WHERE notification_report_generator_logs.notification_template_id = EXCLUDED.notification_template_id +` + +type UpsertNotificationReportGeneratorLogParams struct { + NotificationTemplateID uuid.UUID `db:"notification_template_id" json:"notification_template_id"` + LastGeneratedAt time.Time `db:"last_generated_at" json:"last_generated_at"` +} + +// Insert or update notification report generator logs with recent activity. +func (q *sqlQuerier) UpsertNotificationReportGeneratorLog(ctx context.Context, arg UpsertNotificationReportGeneratorLogParams) error { + _, err := q.db.ExecContext(ctx, upsertNotificationReportGeneratorLog, arg.NotificationTemplateID, arg.LastGeneratedAt) + return err +} + const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec DELETE FROM oauth2_provider_apps WHERE id = $1 ` @@ -12896,6 +12930,83 @@ func (q *sqlQuerier) GetActiveWorkspaceBuildsByTemplateID(ctx context.Context, t return items, nil } +const getFailedWorkspaceBuildsByTemplateID = `-- name: GetFailedWorkspaceBuildsByTemplateID :many +SELECT + tv.name AS template_version_name, + u.username AS workspace_owner_username, + w.name AS workspace_name, + wb.build_number AS workspace_build_number +FROM + workspace_build_with_user AS wb +JOIN + workspaces AS w +ON + wb.workspace_id = w.id +JOIN + users AS u +ON + w.owner_id = u.id +JOIN + provisioner_jobs AS pj +ON + wb.job_id = pj.id +JOIN + templates AS t +ON + w.template_id = t.id +JOIN + template_versions AS tv +ON + wb.template_version_id = tv.id +WHERE + w.template_id = $1 + AND wb.created_at >= $2 + AND pj.completed_at IS NOT NULL + AND pj.job_status = 'failed' +ORDER BY + tv.name ASC, wb.build_number DESC +` + +type GetFailedWorkspaceBuildsByTemplateIDParams struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Since time.Time `db:"since" json:"since"` +} + +type GetFailedWorkspaceBuildsByTemplateIDRow struct { + TemplateVersionName string `db:"template_version_name" json:"template_version_name"` + WorkspaceOwnerUsername string `db:"workspace_owner_username" json:"workspace_owner_username"` + WorkspaceName string `db:"workspace_name" json:"workspace_name"` + WorkspaceBuildNumber int32 `db:"workspace_build_number" json:"workspace_build_number"` +} + +func (q *sqlQuerier) GetFailedWorkspaceBuildsByTemplateID(ctx context.Context, arg GetFailedWorkspaceBuildsByTemplateIDParams) ([]GetFailedWorkspaceBuildsByTemplateIDRow, error) { + rows, err := q.db.QueryContext(ctx, getFailedWorkspaceBuildsByTemplateID, arg.TemplateID, arg.Since) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetFailedWorkspaceBuildsByTemplateIDRow + for rows.Next() { + var i GetFailedWorkspaceBuildsByTemplateIDRow + if err := rows.Scan( + &i.TemplateVersionName, + &i.WorkspaceOwnerUsername, + &i.WorkspaceName, + &i.WorkspaceBuildNumber, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getLatestWorkspaceBuildByWorkspaceID = `-- name: GetLatestWorkspaceBuildByWorkspaceID :one SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, initiator_by_avatar_url, initiator_by_username @@ -13154,6 +13265,73 @@ func (q *sqlQuerier) GetWorkspaceBuildByWorkspaceIDAndBuildNumber(ctx context.Co return i, err } +const getWorkspaceBuildStatsByTemplates = `-- name: GetWorkspaceBuildStatsByTemplates :many +SELECT + w.template_id, + t.name AS template_name, + t.display_name AS template_display_name, + t.organization_id AS template_organization_id, + COUNT(*) AS total_builds, + COUNT(CASE WHEN pj.job_status = 'failed' THEN 1 END) AS failed_builds +FROM + workspace_build_with_user AS wb +JOIN + workspaces AS w ON + wb.workspace_id = w.id +JOIN + provisioner_jobs AS pj ON + wb.job_id = pj.id +JOIN + templates AS t ON + w.template_id = t.id +WHERE + wb.created_at >= $1 + AND pj.completed_at IS NOT NULL +GROUP BY + w.template_id, template_name, template_display_name, template_organization_id +ORDER BY + template_name ASC +` + +type GetWorkspaceBuildStatsByTemplatesRow struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + TemplateName string `db:"template_name" json:"template_name"` + TemplateDisplayName string `db:"template_display_name" json:"template_display_name"` + TemplateOrganizationID uuid.UUID `db:"template_organization_id" json:"template_organization_id"` + TotalBuilds int64 `db:"total_builds" json:"total_builds"` + FailedBuilds int64 `db:"failed_builds" json:"failed_builds"` +} + +func (q *sqlQuerier) GetWorkspaceBuildStatsByTemplates(ctx context.Context, since time.Time) ([]GetWorkspaceBuildStatsByTemplatesRow, error) { + rows, err := q.db.QueryContext(ctx, getWorkspaceBuildStatsByTemplates, since) + if err != nil { + return nil, err + } + defer rows.Close() + var items []GetWorkspaceBuildStatsByTemplatesRow + for rows.Next() { + var i GetWorkspaceBuildStatsByTemplatesRow + if err := rows.Scan( + &i.TemplateID, + &i.TemplateName, + &i.TemplateDisplayName, + &i.TemplateOrganizationID, + &i.TotalBuilds, + &i.FailedBuilds, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const getWorkspaceBuildsByWorkspaceID = `-- name: GetWorkspaceBuildsByWorkspaceID :many SELECT id, created_at, updated_at, workspace_id, template_version_id, build_number, transition, initiator_id, provisioner_state, job_id, deadline, reason, daily_cost, max_deadline, initiator_by_avatar_url, initiator_by_username diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index fa916c95179af..f2d1a14c3aae7 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -174,3 +174,18 @@ SELECT * FROM notification_templates WHERE kind = @kind::notification_template_kind ORDER BY name ASC; + +-- name: GetNotificationReportGeneratorLogByTemplate :one +-- Fetch the notification report generator log indicating recent activity. +SELECT + * +FROM + notification_report_generator_logs +WHERE + notification_template_id = @template_id::uuid; + +-- name: UpsertNotificationReportGeneratorLog :exec +-- Insert or update notification report generator logs with recent activity. +INSERT INTO notification_report_generator_logs (notification_template_id, last_generated_at) VALUES (@notification_template_id, @last_generated_at) +ON CONFLICT (notification_template_id) DO UPDATE set last_generated_at = EXCLUDED.last_generated_at +WHERE notification_report_generator_logs.notification_template_id = EXCLUDED.notification_template_id; diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 2a1107ef75c5c..7050b61644e86 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -179,3 +179,66 @@ WHERE wb.transition = 'start'::workspace_transition AND pj.completed_at IS NOT NULL; + +-- name: GetWorkspaceBuildStatsByTemplates :many +SELECT + w.template_id, + t.name AS template_name, + t.display_name AS template_display_name, + t.organization_id AS template_organization_id, + COUNT(*) AS total_builds, + COUNT(CASE WHEN pj.job_status = 'failed' THEN 1 END) AS failed_builds +FROM + workspace_build_with_user AS wb +JOIN + workspaces AS w ON + wb.workspace_id = w.id +JOIN + provisioner_jobs AS pj ON + wb.job_id = pj.id +JOIN + templates AS t ON + w.template_id = t.id +WHERE + wb.created_at >= @since + AND pj.completed_at IS NOT NULL +GROUP BY + w.template_id, template_name, template_display_name, template_organization_id +ORDER BY + template_name ASC; + +-- name: GetFailedWorkspaceBuildsByTemplateID :many +SELECT + tv.name AS template_version_name, + u.username AS workspace_owner_username, + w.name AS workspace_name, + wb.build_number AS workspace_build_number +FROM + workspace_build_with_user AS wb +JOIN + workspaces AS w +ON + wb.workspace_id = w.id +JOIN + users AS u +ON + w.owner_id = u.id +JOIN + provisioner_jobs AS pj +ON + wb.job_id = pj.id +JOIN + templates AS t +ON + w.template_id = t.id +JOIN + template_versions AS tv +ON + wb.template_version_id = tv.id +WHERE + w.template_id = $1 + AND wb.created_at >= @since + AND pj.completed_at IS NOT NULL + AND pj.job_status = 'failed' +ORDER BY + tv.name ASC, wb.build_number DESC; diff --git a/coderd/database/unique_constraint.go b/coderd/database/unique_constraint.go index 01a811af9c5ed..92e1515475420 100644 --- a/coderd/database/unique_constraint.go +++ b/coderd/database/unique_constraint.go @@ -26,6 +26,7 @@ const ( UniqueLicensesPkey UniqueConstraint = "licenses_pkey" // ALTER TABLE ONLY licenses ADD CONSTRAINT licenses_pkey PRIMARY KEY (id); UniqueNotificationMessagesPkey UniqueConstraint = "notification_messages_pkey" // ALTER TABLE ONLY notification_messages ADD CONSTRAINT notification_messages_pkey PRIMARY KEY (id); UniqueNotificationPreferencesPkey UniqueConstraint = "notification_preferences_pkey" // ALTER TABLE ONLY notification_preferences ADD CONSTRAINT notification_preferences_pkey PRIMARY KEY (user_id, notification_template_id); + UniqueNotificationReportGeneratorLogsPkey UniqueConstraint = "notification_report_generator_logs_pkey" // ALTER TABLE ONLY notification_report_generator_logs ADD CONSTRAINT notification_report_generator_logs_pkey PRIMARY KEY (notification_template_id); UniqueNotificationTemplatesNameKey UniqueConstraint = "notification_templates_name_key" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_name_key UNIQUE (name); UniqueNotificationTemplatesPkey UniqueConstraint = "notification_templates_pkey" // ALTER TABLE ONLY notification_templates ADD CONSTRAINT notification_templates_pkey PRIMARY KEY (id); UniqueOauth2ProviderAppCodesPkey UniqueConstraint = "oauth2_provider_app_codes_pkey" // ALTER TABLE ONLY oauth2_provider_app_codes ADD CONSTRAINT oauth2_provider_app_codes_pkey PRIMARY KEY (id); diff --git a/coderd/notifications/enqueuer.go b/coderd/notifications/enqueuer.go index 3a2cdaac687ca..260fcd2675278 100644 --- a/coderd/notifications/enqueuer.go +++ b/coderd/notifications/enqueuer.go @@ -52,9 +52,14 @@ func NewStoreEnqueuer(cfg codersdk.NotificationsConfig, store Store, helpers tem }, nil } +// Enqueue queues a notification message for later delivery, assumes no structured input data. +func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { + return s.EnqueueWithData(ctx, userID, templateID, labels, nil, createdBy, targets...) +} + // Enqueue queues a notification message for later delivery. // Messages will be dequeued by a notifier later and dispatched. -func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { +func (s *StoreEnqueuer) EnqueueWithData(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ UserID: userID, NotificationTemplateID: templateID, @@ -69,7 +74,7 @@ func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUI dispatchMethod = metadata.CustomMethod.NotificationMethod } - payload, err := s.buildPayload(metadata, labels) + payload, err := s.buildPayload(metadata, labels, data) if err != nil { s.log.Warn(ctx, "failed to build payload", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err)) return nil, xerrors.Errorf("enqueue notification (payload build): %w", err) @@ -119,7 +124,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(metadata database.FetchNewMessageMetadataRow, labels map[string]string) (*types.MessagePayload, error) { +func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRow, labels map[string]string, data map[string]any) (*types.MessagePayload, error) { payload := types.MessagePayload{ Version: "1.1", @@ -132,6 +137,8 @@ func (s *StoreEnqueuer) buildPayload(metadata database.FetchNewMessageMetadataRo UserUsername: metadata.UserUsername, Labels: labels, + Data: data, + // No actions yet } @@ -162,3 +169,8 @@ func (*NoopEnqueuer) Enqueue(context.Context, uuid.UUID, uuid.UUID, map[string]s // nolint:nilnil // irrelevant. return nil, nil } + +func (*NoopEnqueuer) EnqueueWithData(context.Context, uuid.UUID, uuid.UUID, map[string]string, map[string]any, string, ...uuid.UUID) (*uuid.UUID, error) { + // nolint:nilnil // irrelevant. + return nil, nil +} diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 6ba88c239edc8..43406c3012317 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -29,4 +29,6 @@ var ( // Template-related events. var ( TemplateTemplateDeleted = uuid.MustParse("29a09665-2a4c-403f-9648-54301670e7be") + + TemplateWorkspaceBuildsFailedReport = uuid.MustParse("34a20db2-e9cc-4a93-b0e4-8569699d7a00") ) diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 6d8d200939880..3c983b2b3ee3d 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -46,6 +46,7 @@ type Manager struct { notifier *notifier handlers map[database.NotificationMethod]Handler method database.NotificationMethod + helpers template.FuncMap metrics *Metrics @@ -108,6 +109,7 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, helpers template. done: make(chan any), handlers: defaultHandlers(cfg, helpers, log), + helpers: helpers, clock: quartz.NewReal(), } @@ -169,7 +171,7 @@ func (m *Manager) loop(ctx context.Context) error { var eg errgroup.Group // Create a notifier to run concurrently, which will handle dequeueing and dispatching notifications. - m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers, m.metrics, m.clock) + m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers, m.helpers, m.metrics, m.clock) eg.Go(func() error { return m.notifier.run(ctx, m.success, m.failure) }) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 567866a0aaf35..21eee9db66542 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -844,6 +844,56 @@ func TestNotificationTemplatesCanRender(t *testing.T) { }, }, }, + { + name: "TemplateWorkspaceBuildsFailedReport", + id: notifications.TemplateWorkspaceBuildsFailedReport, + payload: types.MessagePayload{ + UserName: "Bobby", + Labels: map[string]string{ + "template_name": "bobby-first-template", + "template_display_name": "Bobby First Template", + }, + Data: map[string]any{ + "failed_builds": 4, + "total_builds": 55, + "report_frequency": "week", + "template_versions": []map[string]any{ + { + "template_version_name": "bobby-template-version-1", + "failed_count": 3, + "failed_builds": []map[string]any{ + { + "workspace_owner_username": "mtojek", + "workspace_name": "workspace-1", + "build_number": 1234, + }, + { + "workspace_owner_username": "johndoe", + "workspace_name": "my-workspace-3", + "build_number": 5678, + }, + { + "workspace_owner_username": "jack", + "workspace_name": "workwork", + "build_number": 774, + }, + }, + }, + { + "template_version_name": "bobby-template-version-2", + "failed_count": 1, + "failed_builds": []map[string]any{ + { + "workspace_owner_username": "ben", + "workspace_name": "cool-workspace", + "build_number": 8888, + }, + }, + }, + }, + }, + }, + }, } allTemplates, err := enumerateAllTemplates(t) diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 0bfaa04324327..a3ca9fc931aa1 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "sync" + "text/template" "github.com/google/uuid" "golang.org/x/sync/errgroup" @@ -36,13 +37,14 @@ type notifier struct { handlers map[database.NotificationMethod]Handler metrics *Metrics + helpers template.FuncMap // clock is for testing clock quartz.Clock } func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, - hr map[database.NotificationMethod]Handler, metrics *Metrics, clock quartz.Clock, + hr map[database.NotificationMethod]Handler, helpers template.FuncMap, metrics *Metrics, clock quartz.Clock, ) *notifier { tick := clock.NewTicker(cfg.FetchInterval.Value(), "notifier", "fetchInterval") return ¬ifier{ @@ -54,6 +56,7 @@ func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger tick: tick, store: db, handlers: hr, + helpers: helpers, metrics: metrics, clock: clock, } @@ -221,10 +224,10 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification } var title, body string - if title, err = render.GoTemplate(msg.TitleTemplate, payload, nil); err != nil { + if title, err = render.GoTemplate(msg.TitleTemplate, payload, n.helpers); err != nil { return nil, xerrors.Errorf("render title: %w", err) } - if body, err = render.GoTemplate(msg.BodyTemplate, payload, nil); err != nil { + if body, err = render.GoTemplate(msg.BodyTemplate, payload, n.helpers); err != nil { return nil, xerrors.Errorf("render body: %w", err) } diff --git a/coderd/notifications/reports/generator.go b/coderd/notifications/reports/generator.go new file mode 100644 index 0000000000000..b76ef5374bc15 --- /dev/null +++ b/coderd/notifications/reports/generator.go @@ -0,0 +1,300 @@ +package reports + +import ( + "context" + "database/sql" + "io" + "slices" + "sort" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/quartz" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/codersdk" +) + +const ( + delay = 15 * time.Minute +) + +func NewReportGenerator(ctx context.Context, logger slog.Logger, db database.Store, enqueuer notifications.Enqueuer, clk quartz.Clock) io.Closer { + closed := make(chan struct{}) + + ctx, cancelFunc := context.WithCancel(ctx) + //nolint:gocritic // The system generates periodic reports without direct user input. + ctx = dbauthz.AsSystemRestricted(ctx) + + // Start the ticker with the initial delay. + ticker := clk.NewTicker(delay) + ticker.Stop() + doTick := func(start time.Time) { + defer ticker.Reset(delay) + // Start a transaction to grab advisory lock, we don't want to run generator jobs at the same time (multiple replicas). + if err := db.InTx(func(tx database.Store) error { + // Acquire a lock to ensure that only one instance of the generator is running at a time. + ok, err := tx.TryAcquireLock(ctx, database.LockIDNotificationsReportGenerator) + if err != nil { + return xerrors.Errorf("failed to acquire report generator lock: %w", err) + } + if !ok { + logger.Debug(ctx, "unable to acquire lock for generating periodic reports, skipping") + return nil + } + + err = reportFailedWorkspaceBuilds(ctx, logger, db, enqueuer, clk) + if err != nil { + return xerrors.Errorf("unable to generate reports with failed workspace builds: %w", err) + } + + logger.Info(ctx, "report generator finished", slog.F("duration", clk.Since(start))) + + return nil + }, nil); err != nil { + logger.Error(ctx, "failed to generate reports", slog.Error(err)) + return + } + } + + go func() { + defer close(closed) + defer ticker.Stop() + // Force an initial tick. + doTick(dbtime.Time(clk.Now()).UTC()) + for { + select { + case <-ctx.Done(): + logger.Debug(ctx, "closing report generator") + return + case tick := <-ticker.C: + ticker.Stop() + doTick(dbtime.Time(tick).UTC()) + } + } + }() + return &reportGenerator{ + cancel: cancelFunc, + closed: closed, + } +} + +type reportGenerator struct { + cancel context.CancelFunc + closed chan struct{} +} + +func (i *reportGenerator) Close() error { + i.cancel() + <-i.closed + return nil +} + +const ( + failedWorkspaceBuildsReportFrequency = 7 * 24 * time.Hour + failedWorkspaceBuildsReportFrequencyLabel = "week" +) + +func reportFailedWorkspaceBuilds(ctx context.Context, logger slog.Logger, db database.Store, enqueuer notifications.Enqueuer, clk quartz.Clock) error { + now := clk.Now() + since := now.Add(-failedWorkspaceBuildsReportFrequency) + + // Firstly, check if this is the first run of the job ever + reportLog, err := db.GetNotificationReportGeneratorLogByTemplate(ctx, notifications.TemplateWorkspaceBuildsFailedReport) + if err != nil && !xerrors.Is(err, sql.ErrNoRows) { + return xerrors.Errorf("unable to read report generator log: %w", err) + } + if xerrors.Is(err, sql.ErrNoRows) { + // First run? Check-in the job, and get back after one week. + logger.Info(ctx, "report generator is executing the job for the first time", slog.F("notification_template_id", notifications.TemplateWorkspaceBuildsFailedReport)) + + err = db.UpsertNotificationReportGeneratorLog(ctx, database.UpsertNotificationReportGeneratorLogParams{ + NotificationTemplateID: notifications.TemplateWorkspaceBuildsFailedReport, + LastGeneratedAt: dbtime.Time(now).UTC(), + }) + if err != nil { + return xerrors.Errorf("unable to update report generator logs (first time execution): %w", err) + } + return nil + } + + // Secondly, check if the job has not been running recently + if !reportLog.LastGeneratedAt.IsZero() && reportLog.LastGeneratedAt.Add(failedWorkspaceBuildsReportFrequency).After(now) { + return nil // reports sent recently, no need to send them now + } + + // Thirdly, fetch workspace build stats by templates + templateStatsRows, err := db.GetWorkspaceBuildStatsByTemplates(ctx, dbtime.Time(since).UTC()) + if err != nil { + return xerrors.Errorf("unable to fetch failed workspace builds: %w", err) + } + + for _, stats := range templateStatsRows { + select { + case <-ctx.Done(): + logger.Debug(ctx, "context is canceled, quitting", slog.Error(ctx.Err())) + break + default: + } + + if stats.FailedBuilds == 0 { + logger.Info(ctx, "no failed workspace builds found for template", slog.F("template_id", stats.TemplateID), slog.Error(err)) + continue + } + + // Fetch template admins with org access to the templates + templateAdmins, err := findTemplateAdmins(ctx, db, stats) + if err != nil { + logger.Error(ctx, "unable to find template admins for template", slog.F("template_id", stats.TemplateID), slog.Error(err)) + continue + } + + // Fetch failed builds by the template + failedBuilds, err := db.GetFailedWorkspaceBuildsByTemplateID(ctx, database.GetFailedWorkspaceBuildsByTemplateIDParams{ + TemplateID: stats.TemplateID, + Since: dbtime.Time(since).UTC(), + }) + if err != nil { + logger.Error(ctx, "unable to fetch failed workspace builds", slog.F("template_id", stats.TemplateID), slog.Error(err)) + continue + } + reportData := buildDataForReportFailedWorkspaceBuilds(stats, failedBuilds) + + // Send reports to template admins + templateDisplayName := stats.TemplateDisplayName + if templateDisplayName == "" { + templateDisplayName = stats.TemplateName + } + + for _, templateAdmin := range templateAdmins { + select { + case <-ctx.Done(): + logger.Debug(ctx, "context is canceled, quitting", slog.Error(ctx.Err())) + break + default: + } + + if _, err := enqueuer.EnqueueWithData(ctx, templateAdmin.ID, notifications.TemplateWorkspaceBuildsFailedReport, + map[string]string{ + "template_name": stats.TemplateName, + "template_display_name": templateDisplayName, + }, + reportData, + "report_generator", + stats.TemplateID, stats.TemplateOrganizationID, + ); err != nil { + logger.Warn(ctx, "failed to send a report with failed workspace builds", slog.Error(err)) + } + } + } + + if xerrors.Is(ctx.Err(), context.Canceled) { + logger.Error(ctx, "report generator job is canceled") + return ctx.Err() + } + + // Lastly, update the timestamp in the generator log. + err = db.UpsertNotificationReportGeneratorLog(ctx, database.UpsertNotificationReportGeneratorLogParams{ + NotificationTemplateID: notifications.TemplateWorkspaceBuildsFailedReport, + LastGeneratedAt: dbtime.Time(now).UTC(), + }) + if err != nil { + return xerrors.Errorf("unable to update report generator logs: %w", err) + } + return nil +} + +const workspaceBuildsLimitPerTemplateVersion = 10 + +func buildDataForReportFailedWorkspaceBuilds(stats database.GetWorkspaceBuildStatsByTemplatesRow, failedBuilds []database.GetFailedWorkspaceBuildsByTemplateIDRow) map[string]any { + // Build notification model for template versions and failed workspace builds. + // + // Failed builds are sorted by template version ascending, workspace build number descending. + // Review builds, group them by template versions, and assign to builds to template versions. + // The map requires `[]map[string]any{}` to be compatible with data passed to `NotificationEnqueuer`. + templateVersions := []map[string]any{} + for _, failedBuild := range failedBuilds { + c := len(templateVersions) + + if c == 0 || templateVersions[c-1]["template_version_name"] != failedBuild.TemplateVersionName { + templateVersions = append(templateVersions, map[string]any{ + "template_version_name": failedBuild.TemplateVersionName, + "failed_count": 1, + "failed_builds": []map[string]any{ + { + "workspace_owner_username": failedBuild.WorkspaceOwnerUsername, + "workspace_name": failedBuild.WorkspaceName, + "build_number": failedBuild.WorkspaceBuildNumber, + }, + }, + }) + continue + } + + tv := templateVersions[c-1] + //nolint:errorlint,forcetypeassert // only this function prepares the notification model + tv["failed_count"] = tv["failed_count"].(int) + 1 + + //nolint:errorlint,forcetypeassert // only this function prepares the notification model + builds := tv["failed_builds"].([]map[string]any) + if len(builds) < workspaceBuildsLimitPerTemplateVersion { + // return N last builds to prevent long email reports + builds = append(builds, map[string]any{ + "workspace_owner_username": failedBuild.WorkspaceOwnerUsername, + "workspace_name": failedBuild.WorkspaceName, + "build_number": failedBuild.WorkspaceBuildNumber, + }) + tv["failed_builds"] = builds + } + templateVersions[c-1] = tv + } + + return map[string]any{ + "failed_builds": stats.FailedBuilds, + "total_builds": stats.TotalBuilds, + "report_frequency": failedWorkspaceBuildsReportFrequencyLabel, + "template_versions": templateVersions, + } +} + +func findTemplateAdmins(ctx context.Context, db database.Store, stats database.GetWorkspaceBuildStatsByTemplatesRow) ([]database.GetUsersRow, error) { + users, err := db.GetUsers(ctx, database.GetUsersParams{ + RbacRole: []string{codersdk.RoleTemplateAdmin}, + }) + if err != nil { + return nil, xerrors.Errorf("unable to fetch template admins: %w", err) + } + + var templateAdmins []database.GetUsersRow + if len(users) == 0 { + return templateAdmins, nil + } + + usersByIDs := map[uuid.UUID]database.GetUsersRow{} + var userIDs []uuid.UUID + for _, user := range users { + usersByIDs[user.ID] = user + userIDs = append(userIDs, user.ID) + } + + orgIDsByMemberIDs, err := db.GetOrganizationIDsByMemberIDs(ctx, userIDs) + if err != nil { + return nil, xerrors.Errorf("unable to fetch organization IDs by member IDs: %w", err) + } + + for _, entry := range orgIDsByMemberIDs { + if slices.Contains(entry.OrganizationIDs, stats.TemplateOrganizationID) { + templateAdmins = append(templateAdmins, usersByIDs[entry.UserID]) + } + } + sort.Slice(templateAdmins, func(i, j int) bool { + return templateAdmins[i].Username < templateAdmins[j].Username + }) + return templateAdmins, nil +} diff --git a/coderd/notifications/reports/generator_internal_test.go b/coderd/notifications/reports/generator_internal_test.go new file mode 100644 index 0000000000000..a6a7f66f725cf --- /dev/null +++ b/coderd/notifications/reports/generator_internal_test.go @@ -0,0 +1,475 @@ +package reports + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/google/uuid" + "github.com/prometheus/client_golang/prometheus" + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/quartz" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/testutil" +) + +const dayDuration = 24 * time.Hour + +var ( + jobError = sql.NullString{String: "badness", Valid: true} + jobErrorCode = sql.NullString{String: "ERR-42", Valid: true} +) + +func TestReportFailedWorkspaceBuilds(t *testing.T) { + t.Parallel() + + t.Run("EmptyState_NoBuilds_NoReport", func(t *testing.T) { + t.Parallel() + + // Setup + ctx, logger, db, _, notifEnq, clk := setup(t) + + // Database is ready, so we can clear notifications queue + notifEnq.Clear() + + // When: first run + err := reportFailedWorkspaceBuilds(ctx, logger, db, notifEnq, clk) + + // Then: no report should be generated + require.NoError(t, err) + require.Empty(t, notifEnq.Sent) + + // Given: one week later and no jobs were executed + clk.Advance(failedWorkspaceBuildsReportFrequency + time.Minute) + + // When + notifEnq.Clear() + err = reportFailedWorkspaceBuilds(ctx, logger, db, notifEnq, clk) + + // Then: report is still empty + require.NoError(t, err) + require.Empty(t, notifEnq.Sent) + }) + + t.Run("InitialState_NoBuilds_NoReport", func(t *testing.T) { + t.Parallel() + + // Setup + ctx, logger, db, ps, notifEnq, clk := setup(t) + now := clk.Now() + + // Organization + org := dbgen.Organization(t, db, database.Organization{}) + + // Template admins + templateAdmin1 := dbgen.User(t, db, database.User{Username: "template-admin-1", RBACRoles: []string{rbac.RoleTemplateAdmin().Name}}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: templateAdmin1.ID, OrganizationID: org.ID}) + + // Regular users + user1 := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user1.ID, OrganizationID: org.ID}) + user2 := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user2.ID, OrganizationID: org.ID}) + + // Templates + t1 := dbgen.Template(t, db, database.Template{Name: "template-1", DisplayName: "First Template", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID}) + + // Template versions + t1v1 := dbgen.TemplateVersion(t, db, database.TemplateVersion{Name: "template-1-version-1", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, JobID: uuid.New()}) + + // Workspaces + w1 := dbgen.Workspace(t, db, database.Workspace{TemplateID: t1.ID, OwnerID: user1.ID, OrganizationID: org.ID}) + + w1wb1pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: now.Add(-6 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: 1, TemplateVersionID: t1v1.ID, JobID: w1wb1pj.ID, CreatedAt: now.Add(-2 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + // When: first run + notifEnq.Clear() + err := reportFailedWorkspaceBuilds(ctx, logger, db, notifEnq, clk) + + // Then: failed builds should not be reported + require.NoError(t, err) + require.Empty(t, notifEnq.Sent) + + // Given: one week later, but still no jobs + clk.Advance(failedWorkspaceBuildsReportFrequency + time.Minute) + + // When + notifEnq.Clear() + err = reportFailedWorkspaceBuilds(ctx, logger, db, notifEnq, clk) + + // Then: report is still empty + require.NoError(t, err) + require.Empty(t, notifEnq.Sent) + }) + + t.Run("FailedBuilds_SecondRun_Report_ThirdRunTooEarly_NoReport_FourthRun_Report", func(t *testing.T) { + t.Parallel() + + verifyNotification := func(t *testing.T, recipient database.User, notif *testutil.Notification, tmpl database.Template, failedBuilds, totalBuilds int64, templateVersions []map[string]interface{}) { + t.Helper() + + require.Equal(t, recipient.ID, notif.UserID) + require.Equal(t, notifications.TemplateWorkspaceBuildsFailedReport, notif.TemplateID) + require.Equal(t, tmpl.Name, notif.Labels["template_name"]) + require.Equal(t, tmpl.DisplayName, notif.Labels["template_display_name"]) + require.Equal(t, failedBuilds, notif.Data["failed_builds"]) + require.Equal(t, totalBuilds, notif.Data["total_builds"]) + require.Equal(t, "week", notif.Data["report_frequency"]) + require.Equal(t, templateVersions, notif.Data["template_versions"]) + } + + // Setup + ctx, logger, db, ps, notifEnq, clk := setup(t) + + // Given + + // Organization + org := dbgen.Organization(t, db, database.Organization{}) + + // Template admins + templateAdmin1 := dbgen.User(t, db, database.User{Username: "template-admin-1", RBACRoles: []string{rbac.RoleTemplateAdmin().Name}}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: templateAdmin1.ID, OrganizationID: org.ID}) + templateAdmin2 := dbgen.User(t, db, database.User{Username: "template-admin-2", RBACRoles: []string{rbac.RoleTemplateAdmin().Name}}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: templateAdmin2.ID, OrganizationID: org.ID}) + _ = dbgen.User(t, db, database.User{Name: "template-admin-3", RBACRoles: []string{rbac.RoleTemplateAdmin().Name}}) + // template admin in some other org, they should not receive any notification + + // Regular users + user1 := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user1.ID, OrganizationID: org.ID}) + user2 := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user2.ID, OrganizationID: org.ID}) + + // Templates + t1 := dbgen.Template(t, db, database.Template{Name: "template-1", DisplayName: "First Template", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID}) + t2 := dbgen.Template(t, db, database.Template{Name: "template-2", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID}) + + // Template versions + t1v1 := dbgen.TemplateVersion(t, db, database.TemplateVersion{Name: "template-1-version-1", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, JobID: uuid.New()}) + t1v2 := dbgen.TemplateVersion(t, db, database.TemplateVersion{Name: "template-1-version-2", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, JobID: uuid.New()}) + t2v1 := dbgen.TemplateVersion(t, db, database.TemplateVersion{Name: "template-2-version-1", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID, TemplateID: uuid.NullUUID{UUID: t2.ID, Valid: true}, JobID: uuid.New()}) + t2v2 := dbgen.TemplateVersion(t, db, database.TemplateVersion{Name: "template-2-version-2", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID, TemplateID: uuid.NullUUID{UUID: t2.ID, Valid: true}, JobID: uuid.New()}) + + // Workspaces + w1 := dbgen.Workspace(t, db, database.Workspace{TemplateID: t1.ID, OwnerID: user1.ID, OrganizationID: org.ID}) + w2 := dbgen.Workspace(t, db, database.Workspace{TemplateID: t2.ID, OwnerID: user2.ID, OrganizationID: org.ID}) + w3 := dbgen.Workspace(t, db, database.Workspace{TemplateID: t1.ID, OwnerID: user1.ID, OrganizationID: org.ID}) + w4 := dbgen.Workspace(t, db, database.Workspace{TemplateID: t2.ID, OwnerID: user2.ID, OrganizationID: org.ID}) + + // When: first run + notifEnq.Clear() + err := reportFailedWorkspaceBuilds(ctx, logger, db, notifEnq, clk) + + // Then + require.NoError(t, err) + require.Empty(t, notifEnq.Sent) // no notifications + + // One week later... + clk.Advance(failedWorkspaceBuildsReportFrequency + time.Minute) + now := clk.Now() + + // Workspace builds + w1wb1pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: now.Add(-6 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: 1, TemplateVersionID: t1v1.ID, JobID: w1wb1pj.ID, CreatedAt: now.Add(-6 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + w1wb2pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, CompletedAt: sql.NullTime{Time: now.Add(-5 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: 2, TemplateVersionID: t1v2.ID, JobID: w1wb2pj.ID, CreatedAt: now.Add(-5 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + w1wb3pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: now.Add(-4 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: 3, TemplateVersionID: t1v2.ID, JobID: w1wb3pj.ID, CreatedAt: now.Add(-4 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + w2wb1pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, CompletedAt: sql.NullTime{Time: now.Add(-5 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w2.ID, BuildNumber: 4, TemplateVersionID: t2v1.ID, JobID: w2wb1pj.ID, CreatedAt: now.Add(-5 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + w2wb2pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: now.Add(-4 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w2.ID, BuildNumber: 5, TemplateVersionID: t2v2.ID, JobID: w2wb2pj.ID, CreatedAt: now.Add(-4 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + w2wb3pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: now.Add(-3 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w2.ID, BuildNumber: 6, TemplateVersionID: t2v2.ID, JobID: w2wb3pj.ID, CreatedAt: now.Add(-3 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + w3wb1pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: now.Add(-3 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w3.ID, BuildNumber: 7, TemplateVersionID: t1v1.ID, JobID: w3wb1pj.ID, CreatedAt: now.Add(-3 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + w4wb1pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: now.Add(-6 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w4.ID, BuildNumber: 8, TemplateVersionID: t2v1.ID, JobID: w4wb1pj.ID, CreatedAt: now.Add(-6 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + w4wb2pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, CompletedAt: sql.NullTime{Time: now.Add(-dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w4.ID, BuildNumber: 9, TemplateVersionID: t2v2.ID, JobID: w4wb2pj.ID, CreatedAt: now.Add(-dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + // When + notifEnq.Clear() + err = reportFailedWorkspaceBuilds(ctx, logger, authedDB(t, db, logger), notifEnq, clk) + + // Then + require.NoError(t, err) + + require.Len(t, notifEnq.Sent, 4) // 2 templates, 2 template admins + for i, templateAdmin := range []database.User{templateAdmin1, templateAdmin2} { + verifyNotification(t, templateAdmin, notifEnq.Sent[i], t1, 3, 4, []map[string]interface{}{ + { + "failed_builds": []map[string]interface{}{ + {"build_number": int32(7), "workspace_name": w3.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(1), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + }, + "failed_count": 2, + "template_version_name": t1v1.Name, + }, + { + "failed_builds": []map[string]interface{}{ + {"build_number": int32(3), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + }, + "failed_count": 1, + "template_version_name": t1v2.Name, + }, + }) + } + + for i, templateAdmin := range []database.User{templateAdmin1, templateAdmin2} { + verifyNotification(t, templateAdmin, notifEnq.Sent[i+2], t2, 3, 5, []map[string]interface{}{ + { + "failed_builds": []map[string]interface{}{ + {"build_number": int32(8), "workspace_name": w4.Name, "workspace_owner_username": user2.Username}, + }, + "failed_count": 1, + "template_version_name": t2v1.Name, + }, + { + "failed_builds": []map[string]interface{}{ + {"build_number": int32(6), "workspace_name": w2.Name, "workspace_owner_username": user2.Username}, + {"build_number": int32(5), "workspace_name": w2.Name, "workspace_owner_username": user2.Username}, + }, + "failed_count": 2, + "template_version_name": t2v2.Name, + }, + }) + } + + // Given: 6 days later (less than report frequency), and failed build + clk.Advance(6 * dayDuration).MustWait(context.Background()) + now = clk.Now() + + w1wb4pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: now.Add(-dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: 77, TemplateVersionID: t1v2.ID, JobID: w1wb4pj.ID, CreatedAt: now.Add(-dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + // When + notifEnq.Clear() + err = reportFailedWorkspaceBuilds(ctx, logger, authedDB(t, db, logger), notifEnq, clk) + require.NoError(t, err) + + // Then: no notifications as it is too early + require.Empty(t, notifEnq.Sent) + + // Given: 1 day 1 hour later + clk.Advance(dayDuration + time.Hour).MustWait(context.Background()) + + // When + notifEnq.Clear() + err = reportFailedWorkspaceBuilds(ctx, logger, authedDB(t, db, logger), notifEnq, clk) + require.NoError(t, err) + + // Then: we should see the failed job in the report + require.Len(t, notifEnq.Sent, 2) // a new failed job should be reported + for i, templateAdmin := range []database.User{templateAdmin1, templateAdmin2} { + verifyNotification(t, templateAdmin, notifEnq.Sent[i], t1, 1, 1, []map[string]interface{}{ + { + "failed_builds": []map[string]interface{}{ + {"build_number": int32(77), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + }, + "failed_count": 1, + "template_version_name": t1v2.Name, + }, + }) + } + }) + + t.Run("TooManyFailedBuilds_SecondRun_Report", func(t *testing.T) { + t.Parallel() + + verifyNotification := func(t *testing.T, recipient database.User, notif *testutil.Notification, tmpl database.Template, failedBuilds, totalBuilds int64, templateVersions []map[string]interface{}) { + t.Helper() + + require.Equal(t, recipient.ID, notif.UserID) + require.Equal(t, notifications.TemplateWorkspaceBuildsFailedReport, notif.TemplateID) + require.Equal(t, tmpl.Name, notif.Labels["template_name"]) + require.Equal(t, tmpl.DisplayName, notif.Labels["template_display_name"]) + require.Equal(t, failedBuilds, notif.Data["failed_builds"]) + require.Equal(t, totalBuilds, notif.Data["total_builds"]) + require.Equal(t, "week", notif.Data["report_frequency"]) + require.Equal(t, templateVersions, notif.Data["template_versions"]) + } + + // Setup + ctx, logger, db, ps, notifEnq, clk := setup(t) + + // Given + + // Organization + org := dbgen.Organization(t, db, database.Organization{}) + + // Template admins + templateAdmin1 := dbgen.User(t, db, database.User{Username: "template-admin-1", RBACRoles: []string{rbac.RoleTemplateAdmin().Name}}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: templateAdmin1.ID, OrganizationID: org.ID}) + + // Regular users + user1 := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user1.ID, OrganizationID: org.ID}) + + // Templates + t1 := dbgen.Template(t, db, database.Template{Name: "template-1", DisplayName: "First Template", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID}) + + // Template versions + t1v1 := dbgen.TemplateVersion(t, db, database.TemplateVersion{Name: "template-1-version-1", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, JobID: uuid.New()}) + t1v2 := dbgen.TemplateVersion(t, db, database.TemplateVersion{Name: "template-1-version-2", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, JobID: uuid.New()}) + + // Workspaces + w1 := dbgen.Workspace(t, db, database.Workspace{TemplateID: t1.ID, OwnerID: user1.ID, OrganizationID: org.ID}) + + // When: first run + notifEnq.Clear() + err := reportFailedWorkspaceBuilds(ctx, logger, db, notifEnq, clk) + + // Then + require.NoError(t, err) + require.Empty(t, notifEnq.Sent) // no notifications + + // One week later... + clk.Advance(failedWorkspaceBuildsReportFrequency + time.Minute) + now := clk.Now() + + // Workspace builds + pj0 := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, CompletedAt: sql.NullTime{Time: now.Add(-24 * time.Hour), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: 777, TemplateVersionID: t1v1.ID, JobID: pj0.ID, CreatedAt: now.Add(-24 * time.Hour), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + for i := 1; i <= 23; i++ { + at := now.Add(-time.Duration(i) * time.Hour) + + pj1 := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: at, Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: int32(i), TemplateVersionID: t1v1.ID, JobID: pj1.ID, CreatedAt: at, Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + pj2 := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, Error: jobError, ErrorCode: jobErrorCode, CompletedAt: sql.NullTime{Time: at, Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: int32(i) + 100, TemplateVersionID: t1v2.ID, JobID: pj2.ID, CreatedAt: at, Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + } + + // When + notifEnq.Clear() + err = reportFailedWorkspaceBuilds(ctx, logger, authedDB(t, db, logger), notifEnq, clk) + + // Then + require.NoError(t, err) + + require.Len(t, notifEnq.Sent, 1) // 1 template, 1 template admin + verifyNotification(t, templateAdmin1, notifEnq.Sent[0], t1, 46, 47, []map[string]interface{}{ + { + "failed_builds": []map[string]interface{}{ + {"build_number": int32(23), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(22), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(21), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(20), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(19), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(18), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(17), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(16), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(15), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(14), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + }, + "failed_count": 23, + "template_version_name": t1v1.Name, + }, + { + "failed_builds": []map[string]interface{}{ + {"build_number": int32(123), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(122), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(121), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(120), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(119), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(118), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(117), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(116), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(115), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + {"build_number": int32(114), "workspace_name": w1.Name, "workspace_owner_username": user1.Username}, + }, + "failed_count": 23, + "template_version_name": t1v2.Name, + }, + }) + }) + + t.Run("NoFailedBuilds_NoReport", func(t *testing.T) { + t.Parallel() + + // Setup + ctx, logger, db, ps, notifEnq, clk := setup(t) + + // Given + // Organization + org := dbgen.Organization(t, db, database.Organization{}) + + // Template admins + templateAdmin1 := dbgen.User(t, db, database.User{Username: "template-admin-1", RBACRoles: []string{rbac.RoleTemplateAdmin().Name}}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: templateAdmin1.ID, OrganizationID: org.ID}) + + // Regular users + user1 := dbgen.User(t, db, database.User{}) + _ = dbgen.OrganizationMember(t, db, database.OrganizationMember{UserID: user1.ID, OrganizationID: org.ID}) + + // Templates + t1 := dbgen.Template(t, db, database.Template{Name: "template-1", DisplayName: "First Template", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID}) + + // Template versions + t1v1 := dbgen.TemplateVersion(t, db, database.TemplateVersion{Name: "template-1-version-1", CreatedBy: templateAdmin1.ID, OrganizationID: org.ID, TemplateID: uuid.NullUUID{UUID: t1.ID, Valid: true}, JobID: uuid.New()}) + + // Workspaces + w1 := dbgen.Workspace(t, db, database.Workspace{TemplateID: t1.ID, OwnerID: user1.ID, OrganizationID: org.ID}) + + // When: first run + notifEnq.Clear() + err := reportFailedWorkspaceBuilds(ctx, logger, db, notifEnq, clk) + + // Then: no notifications + require.NoError(t, err) + require.Empty(t, notifEnq.Sent) + + // Given: one week later, and a successful few jobs being executed + clk.Advance(failedWorkspaceBuildsReportFrequency + time.Minute) + now := clk.Now() + + // Workspace builds + w1wb1pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, CompletedAt: sql.NullTime{Time: now.Add(-6 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: 1, TemplateVersionID: t1v1.ID, JobID: w1wb1pj.ID, CreatedAt: now.Add(-2 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + w1wb2pj := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{OrganizationID: org.ID, CompletedAt: sql.NullTime{Time: now.Add(-5 * dayDuration), Valid: true}}) + _ = dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{WorkspaceID: w1.ID, BuildNumber: 2, TemplateVersionID: t1v1.ID, JobID: w1wb2pj.ID, CreatedAt: now.Add(-1 * dayDuration), Transition: database.WorkspaceTransitionStart, Reason: database.BuildReasonInitiator}) + + // When + notifEnq.Clear() + err = reportFailedWorkspaceBuilds(ctx, logger, authedDB(t, db, logger), notifEnq, clk) + + // Then: no failures? nothing to report + require.NoError(t, err) + require.Len(t, notifEnq.Sent, 0) // all jobs succeeded so nothing to report + }) +} + +func setup(t *testing.T) (context.Context, slog.Logger, database.Store, pubsub.Pubsub, *testutil.FakeNotificationsEnqueuer, *quartz.Mock) { + t.Helper() + + // nolint:gocritic // reportFailedWorkspaceBuilds is called by system. + ctx := dbauthz.AsSystemRestricted(context.Background()) + logger := slogtest.Make(t, &slogtest.Options{}) + db, ps := dbtestutil.NewDB(t) + notifyEnq := &testutil.FakeNotificationsEnqueuer{} + clk := quartz.NewMock(t) + return ctx, logger, db, ps, notifyEnq, clk +} + +func authedDB(t *testing.T, db database.Store, logger slog.Logger) database.Store { + t.Helper() + return dbauthz.New(db, rbac.NewAuthorizer(prometheus.NewRegistry()), logger, coderdtest.AccessControlStorePointer()) +} diff --git a/coderd/notifications/spec.go b/coderd/notifications/spec.go index c41189ba3d582..b8ae063cc919e 100644 --- a/coderd/notifications/spec.go +++ b/coderd/notifications/spec.go @@ -33,4 +33,5 @@ type Handler interface { // Enqueuer enqueues a new notification message in the store and returns its ID, should it enqueue without failure. type Enqueuer interface { Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) + EnqueueWithData(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) } diff --git a/coderd/notifications/testdata/rendered-templates/TemplateWorkspaceBuildsFailedReport-body.md.golden b/coderd/notifications/testdata/rendered-templates/TemplateWorkspaceBuildsFailedReport-body.md.golden new file mode 100644 index 0000000000000..e896a0a8c9e51 --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/TemplateWorkspaceBuildsFailedReport-body.md.golden @@ -0,0 +1,17 @@ +Hi Bobby, + +Template **Bobby First Template** has failed to build 4/55 times over the last week. + +**Report:** + +**bobby-template-version-1** failed 3 times: + +* [mtojek / workspace-1 / #1234](http://test.com/@mtojek/workspace-1/builds/1234) +* [johndoe / my-workspace-3 / #5678](http://test.com/@johndoe/my-workspace-3/builds/5678) +* [jack / workwork / #774](http://test.com/@jack/workwork/builds/774) + +**bobby-template-version-2** failed 1 time: + +* [ben / cool-workspace / #8888](http://test.com/@ben/cool-workspace/builds/8888) + +We recommend reviewing these issues to ensure future builds are successful. \ No newline at end of file diff --git a/coderd/notifications/testdata/rendered-templates/TemplateWorkspaceBuildsFailedReport-title.md.golden b/coderd/notifications/testdata/rendered-templates/TemplateWorkspaceBuildsFailedReport-title.md.golden new file mode 100644 index 0000000000000..f03f8fca96c7c --- /dev/null +++ b/coderd/notifications/testdata/rendered-templates/TemplateWorkspaceBuildsFailedReport-title.md.golden @@ -0,0 +1 @@ +Workspace builds failed for template "Bobby First Template" \ No newline at end of file diff --git a/coderd/notifications/types/payload.go b/coderd/notifications/types/payload.go index fbcec19bf76ed..dbd21c29be517 100644 --- a/coderd/notifications/types/payload.go +++ b/coderd/notifications/types/payload.go @@ -17,4 +17,5 @@ type MessagePayload struct { Actions []TemplateAction `json:"actions"` Labels map[string]string `json:"labels"` + Data map[string]any `json:"data"` } diff --git a/testutil/notifications.go b/testutil/notifications.go index a8d6486209d2a..379218cd379e8 100644 --- a/testutil/notifications.go +++ b/testutil/notifications.go @@ -15,11 +15,16 @@ type FakeNotificationsEnqueuer struct { type Notification struct { UserID, TemplateID uuid.UUID Labels map[string]string + Data map[string]any CreatedBy string Targets []uuid.UUID } -func (f *FakeNotificationsEnqueuer) Enqueue(_ context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { +func (f *FakeNotificationsEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { + return f.EnqueueWithData(ctx, userID, templateID, labels, nil, createdBy, targets...) +} + +func (f *FakeNotificationsEnqueuer) EnqueueWithData(_ context.Context, userID, templateID uuid.UUID, labels map[string]string, data map[string]any, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { f.mu.Lock() defer f.mu.Unlock() @@ -27,6 +32,7 @@ func (f *FakeNotificationsEnqueuer) Enqueue(_ context.Context, userID, templateI UserID: userID, TemplateID: templateID, Labels: labels, + Data: data, CreatedBy: createdBy, Targets: targets, })