From 735d0efb9a5dcaa4f11277151f4d7458c95785ad Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Tue, 9 Jul 2024 14:30:46 +0200 Subject: [PATCH 01/20] feat: add killswitch for notifications --- coderd/apidoc/docs.go | 72 +++++++++++ coderd/apidoc/swagger.json | 62 +++++++++ coderd/audit/diff.go | 1 + coderd/audit/request.go | 10 ++ coderd/coderd.go | 5 + coderd/database/dbauthz/dbauthz.go | 12 ++ coderd/database/dbauthz/dbauthz_test.go | 6 + coderd/database/dbmem/dbmem.go | 20 +++ coderd/database/dbmetrics/dbmetrics.go | 14 ++ coderd/database/dbmock/dbmock.go | 29 +++++ coderd/database/dbpurge/dbpurge_test.go | 4 +- coderd/database/dump.sql | 3 +- ...0223_notifications_settings_audit.down.sql | 1 + ...000223_notifications_settings_audit.up.sql | 2 + coderd/database/models.go | 5 +- coderd/database/querier.go | 2 + coderd/database/queries.sql.go | 22 ++++ coderd/database/queries/siteconfig.sql | 10 ++ coderd/database/types.go | 5 + coderd/notifications.go | 121 ++++++++++++++++++ coderd/notifications/manager_test.go | 2 +- coderd/notifications/notifications_test.go | 107 ++++++++++++++++ coderd/notifications/notifier.go | 33 ++++- coderd/notifications/spec.go | 1 + coderd/notifications_test.go | 79 ++++++++++++ codersdk/audit.go | 31 +++-- codersdk/notifications.go | 42 ++++++ docs/admin/audit-logs.md | 1 + docs/api/general.md | 77 +++++++++++ docs/api/schemas.md | 15 +++ enterprise/audit/table.go | 4 + site/src/api/typesGenerated.ts | 7 + 32 files changed, 783 insertions(+), 22 deletions(-) create mode 100644 coderd/database/migrations/000223_notifications_settings_audit.down.sql create mode 100644 coderd/database/migrations/000223_notifications_settings_audit.up.sql create mode 100644 coderd/notifications.go create mode 100644 coderd/notifications_test.go create mode 100644 codersdk/notifications.go diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 538d67b81fc2d..a832115ac49c0 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1547,6 +1547,68 @@ const docTemplate = `{ } } }, + "/notifications/settings": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Get notifications settings", + "operationId": "get-notifications-settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "General" + ], + "summary": "Update notifications settings", + "operationId": "update-notifications-settings", + "parameters": [ + { + "description": "Notification settings request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -10009,6 +10071,14 @@ const docTemplate = `{ } } }, + "codersdk.NotificationsSettings": { + "type": "object", + "properties": { + "notifier_paused": { + "type": "boolean" + } + } + }, "codersdk.NotificationsWebhookConfig": { "type": "object", "properties": { @@ -11036,6 +11106,7 @@ const docTemplate = `{ "license", "convert_login", "health_settings", + "notifications_settings", "workspace_proxy", "organization", "oauth2_provider_app", @@ -11054,6 +11125,7 @@ const docTemplate = `{ "ResourceTypeLicense", "ResourceTypeConvertLogin", "ResourceTypeHealthSettings", + "ResourceTypeNotificationsSettings", "ResourceTypeWorkspaceProxy", "ResourceTypeOrganization", "ResourceTypeOAuth2ProviderApp", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 49dfde7a6b651..a61fc6fdb1352 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1344,6 +1344,58 @@ } } }, + "/notifications/settings": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Get notifications settings", + "operationId": "get-notifications-settings", + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + } + }, + "put": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "consumes": ["application/json"], + "produces": ["application/json"], + "tags": ["General"], + "summary": "Update notifications settings", + "operationId": "update-notifications-settings", + "parameters": [ + { + "description": "Notification settings request", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.NotificationsSettings" + } + } + } + } + }, "/oauth2-provider/apps": { "get": { "security": [ @@ -8978,6 +9030,14 @@ } } }, + "codersdk.NotificationsSettings": { + "type": "object", + "properties": { + "notifier_paused": { + "type": "boolean" + } + } + }, "codersdk.NotificationsWebhookConfig": { "type": "object", "properties": { @@ -9958,6 +10018,7 @@ "license", "convert_login", "health_settings", + "notifications_settings", "workspace_proxy", "organization", "oauth2_provider_app", @@ -9976,6 +10037,7 @@ "ResourceTypeLicense", "ResourceTypeConvertLogin", "ResourceTypeHealthSettings", + "ResourceTypeNotificationsSettings", "ResourceTypeWorkspaceProxy", "ResourceTypeOrganization", "ResourceTypeOAuth2ProviderApp", diff --git a/coderd/audit/diff.go b/coderd/audit/diff.go index 09ae80c9ddf90..129b904c75b03 100644 --- a/coderd/audit/diff.go +++ b/coderd/audit/diff.go @@ -20,6 +20,7 @@ type Auditable interface { database.WorkspaceProxy | database.AuditOAuthConvertState | database.HealthSettings | + database.NotificationsSettings | database.OAuth2ProviderApp | database.OAuth2ProviderAppSecret | database.CustomRole | diff --git a/coderd/audit/request.go b/coderd/audit/request.go index 1c027fc85527f..403bb13ccf3f8 100644 --- a/coderd/audit/request.go +++ b/coderd/audit/request.go @@ -99,6 +99,8 @@ func ResourceTarget[T Auditable](tgt T) string { return string(typed.ToLoginType) case database.HealthSettings: return "" // no target? + case database.NotificationsSettings: + return "" // no target? case database.OAuth2ProviderApp: return typed.Name case database.OAuth2ProviderAppSecret: @@ -142,6 +144,9 @@ func ResourceID[T Auditable](tgt T) uuid.UUID { case database.HealthSettings: // Artificial ID for auditing purposes return typed.ID + case database.NotificationsSettings: + // Artificial ID for auditing purposes + return typed.ID case database.OAuth2ProviderApp: return typed.ID case database.OAuth2ProviderAppSecret: @@ -183,6 +188,8 @@ func ResourceType[T Auditable](tgt T) database.ResourceType { return database.ResourceTypeConvertLogin case database.HealthSettings: return database.ResourceTypeHealthSettings + case database.NotificationsSettings: + return database.ResourceTypeNotificationsSettings case database.OAuth2ProviderApp: return database.ResourceTypeOauth2ProviderApp case database.OAuth2ProviderAppSecret: @@ -225,6 +232,9 @@ func ResourceRequiresOrgID[T Auditable]() bool { case database.HealthSettings: // Artificial ID for auditing purposes return false + case database.NotificationsSettings: + // Artificial ID for auditing purposes + return false case database.OAuth2ProviderApp: return false case database.OAuth2ProviderAppSecret: diff --git a/coderd/coderd.go b/coderd/coderd.go index 97b8a9337631a..edcd370a95d6a 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1242,6 +1242,11 @@ func New(options *Options) *API { }) }) }) + r.Route("/notifications", func(r chi.Router) { + r.Use(apiKeyMiddleware) + r.Get("/settings", api.notificationsSettings) + r.Put("/settings", api.putNotificationsSettings) + }) }) if options.SwaggerEndpoint { diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 67dadd5d74e19..1feea0c23bbe7 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -1479,6 +1479,11 @@ func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg datab return q.db.GetNotificationMessagesByStatus(ctx, arg) } +func (q *querier) GetNotificationsSettings(ctx context.Context) (string, error) { + // No authz checks + return q.db.GetNotificationsSettings(ctx) +} + func (q *querier) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceOauth2App); err != nil { return database.OAuth2ProviderApp{}, err @@ -3687,6 +3692,13 @@ func (q *querier) UpsertLogoURL(ctx context.Context, value string) error { return q.db.UpsertLogoURL(ctx, value) } +func (q *querier) UpsertNotificationsSettings(ctx context.Context, value string) error { + if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { + return err + } + return q.db.UpsertNotificationsSettings(ctx, value) +} + func (q *querier) UpsertOAuthSigningKey(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceSystem); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index d85192877f87a..c0442f0ae7d9d 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2350,6 +2350,12 @@ func (s *MethodTestSuite) TestSystemFunctions() { s.Run("UpsertHealthSettings", s.Subtest(func(db database.Store, check *expects) { check.Args("foo").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) })) + s.Run("GetNotificationsSettings", s.Subtest(func(db database.Store, check *expects) { + check.Args().Asserts() + })) + s.Run("UpsertNotificationsSettings", s.Subtest(func(db database.Store, check *expects) { + check.Args("foo").Asserts(rbac.ResourceDeploymentConfig, policy.ActionUpdate) + })) s.Run("GetDeploymentWorkspaceAgentStats", s.Subtest(func(db database.Store, check *expects) { check.Args(time.Time{}).Asserts() })) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 3db958cb9a307..494beec843cf1 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -199,6 +199,7 @@ type data struct { lastUpdateCheck []byte announcementBanners []byte healthSettings []byte + notificationsSettings []byte applicationName string logoURL string appSecurityKey string @@ -2760,6 +2761,17 @@ func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg dat return out, nil } +func (q *FakeQuerier) GetNotificationsSettings(_ context.Context) (string, error) { + q.mutex.RLock() + defer q.mutex.RUnlock() + + if q.notificationsSettings == nil { + return "{}", nil + } + + return string(q.notificationsSettings), nil +} + func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { q.mutex.Lock() defer q.mutex.Unlock() @@ -8713,6 +8725,14 @@ func (q *FakeQuerier) UpsertLogoURL(_ context.Context, data string) error { return nil } +func (q *FakeQuerier) UpsertNotificationsSettings(_ context.Context, data string) error { + q.mutex.RLock() + defer q.mutex.RUnlock() + + q.notificationsSettings = []byte(data) + return nil +} + func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) error { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index 0a7ecd4fb5f10..638aeaac14746 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -739,6 +739,13 @@ func (m metricsStore) GetNotificationMessagesByStatus(ctx context.Context, arg d return r0, r1 } +func (m metricsStore) GetNotificationsSettings(ctx context.Context) (string, error) { + start := time.Now() + r0, r1 := m.s.GetNotificationsSettings(ctx) + m.queryLatencies.WithLabelValues("GetNotificationsSettings").Observe(time.Since(start).Seconds()) + return r0, r1 +} + func (m metricsStore) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { start := time.Now() r0, r1 := m.s.GetOAuth2ProviderAppByID(ctx, id) @@ -2300,6 +2307,13 @@ func (m metricsStore) UpsertLogoURL(ctx context.Context, value string) error { return r0 } +func (m metricsStore) UpsertNotificationsSettings(ctx context.Context, value string) error { + start := time.Now() + r0 := m.s.UpsertNotificationsSettings(ctx, value) + m.queryLatencies.WithLabelValues("UpsertNotificationsSettings").Observe(time.Since(start).Seconds()) + return r0 +} + func (m metricsStore) UpsertOAuthSigningKey(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertOAuthSigningKey(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 982a6472ec16c..5fc5403a64f7f 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1467,6 +1467,21 @@ func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(arg0, arg1 any) return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), arg0, arg1) } +// GetNotificationsSettings mocks base method. +func (m *MockStore) GetNotificationsSettings(arg0 context.Context) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationsSettings", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationsSettings indicates an expected call of GetNotificationsSettings. +func (mr *MockStoreMockRecorder) GetNotificationsSettings(arg0 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationsSettings", reflect.TypeOf((*MockStore)(nil).GetNotificationsSettings), arg0) +} + // GetOAuth2ProviderAppByID mocks base method. func (m *MockStore) GetOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderApp, error) { m.ctrl.T.Helper() @@ -4813,6 +4828,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) } +// UpsertNotificationsSettings mocks base method. +func (m *MockStore) UpsertNotificationsSettings(arg0 context.Context, arg1 string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpsertNotificationsSettings", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpsertNotificationsSettings indicates an expected call of UpsertNotificationsSettings. +func (mr *MockStoreMockRecorder) UpsertNotificationsSettings(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertNotificationsSettings", reflect.TypeOf((*MockStore)(nil).UpsertNotificationsSettings), arg0, arg1) +} + // UpsertOAuthSigningKey mocks base method. func (m *MockStore) UpsertOAuthSigningKey(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 4705fb31eec81..4429898266045 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -205,8 +205,8 @@ func TestDeleteOldWorkspaceAgentLogs(t *testing.T) { } return !containsAgentLog(agentLogs, t.Name()) }, testutil.WaitShort, testutil.IntervalFast) - require.NoError(t, err) - require.NotContains(t, agentLogs, t.Name()) + assert.NoError(t, err) + assert.NotContains(t, agentLogs, t.Name()) }) t.Run("AgentConnectedSixDaysAgo_LogsValid", func(t *testing.T) { diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f0b9cb311606f..3f2da45155da1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -163,7 +163,8 @@ CREATE TYPE resource_type AS ENUM ( 'oauth2_provider_app', 'oauth2_provider_app_secret', 'custom_role', - 'organization_member' + 'organization_member', + 'notifications_settings' ); CREATE TYPE startup_script_behavior AS ENUM ( diff --git a/coderd/database/migrations/000223_notifications_settings_audit.down.sql b/coderd/database/migrations/000223_notifications_settings_audit.down.sql new file mode 100644 index 0000000000000..362f597df0911 --- /dev/null +++ b/coderd/database/migrations/000223_notifications_settings_audit.down.sql @@ -0,0 +1 @@ +-- Nothing to do diff --git a/coderd/database/migrations/000223_notifications_settings_audit.up.sql b/coderd/database/migrations/000223_notifications_settings_audit.up.sql new file mode 100644 index 0000000000000..09afa99193166 --- /dev/null +++ b/coderd/database/migrations/000223_notifications_settings_audit.up.sql @@ -0,0 +1,2 @@ +-- This has to be outside a transaction +ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'notifications_settings'; diff --git a/coderd/database/models.go b/coderd/database/models.go index 7f34d7680abf2..4ff84ddc8891f 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1352,6 +1352,7 @@ const ( ResourceTypeOauth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" ResourceTypeCustomRole ResourceType = "custom_role" ResourceTypeOrganizationMember ResourceType = "organization_member" + ResourceTypeNotificationsSettings ResourceType = "notifications_settings" ) func (e *ResourceType) Scan(src interface{}) error { @@ -1407,7 +1408,8 @@ func (e ResourceType) Valid() bool { ResourceTypeOauth2ProviderApp, ResourceTypeOauth2ProviderAppSecret, ResourceTypeCustomRole, - ResourceTypeOrganizationMember: + ResourceTypeOrganizationMember, + ResourceTypeNotificationsSettings: return true } return false @@ -1432,6 +1434,7 @@ func AllResourceTypeValues() []ResourceType { ResourceTypeOauth2ProviderAppSecret, ResourceTypeCustomRole, ResourceTypeOrganizationMember, + ResourceTypeNotificationsSettings, } } diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 75ade1dc12e5e..c4ce70cea28fe 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -161,6 +161,7 @@ type sqlcQuerier interface { GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error) + GetNotificationsSettings(ctx context.Context) (string, error) GetOAuth2ProviderAppByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderApp, error) GetOAuth2ProviderAppCodeByID(ctx context.Context, id uuid.UUID) (OAuth2ProviderAppCode, error) GetOAuth2ProviderAppCodeByPrefix(ctx context.Context, secretPrefix []byte) (OAuth2ProviderAppCode, error) @@ -454,6 +455,7 @@ type sqlcQuerier interface { UpsertJFrogXrayScanByWorkspaceAndAgentID(ctx context.Context, arg UpsertJFrogXrayScanByWorkspaceAndAgentIDParams) error UpsertLastUpdateCheck(ctx context.Context, value string) error UpsertLogoURL(ctx context.Context, value string) error + UpsertNotificationsSettings(ctx context.Context, value string) error UpsertOAuthSigningKey(ctx context.Context, value string) error UpsertProvisionerDaemon(ctx context.Context, arg UpsertProvisionerDaemonParams) (ProvisionerDaemon, error) UpsertTailnetAgent(ctx context.Context, arg UpsertTailnetAgentParams) (TailnetAgent, error) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 95f25ee1dbd11..83be6184706ce 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -6319,6 +6319,18 @@ func (q *sqlQuerier) GetLogoURL(ctx context.Context) (string, error) { return value, err } +const getNotificationsSettings = `-- name: GetNotificationsSettings :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'notifications_settings'), '{}') :: text AS notifications_settings +` + +func (q *sqlQuerier) GetNotificationsSettings(ctx context.Context) (string, error) { + row := q.db.QueryRowContext(ctx, getNotificationsSettings) + var notifications_settings string + err := row.Scan(¬ifications_settings) + return notifications_settings, err +} + const getOAuthSigningKey = `-- name: GetOAuthSigningKey :one SELECT value FROM site_configs WHERE key = 'oauth_signing_key' ` @@ -6431,6 +6443,16 @@ func (q *sqlQuerier) UpsertLogoURL(ctx context.Context, value string) error { return err } +const upsertNotificationsSettings = `-- name: UpsertNotificationsSettings :exec +INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings' +` + +func (q *sqlQuerier) UpsertNotificationsSettings(ctx context.Context, value string) error { + _, err := q.db.ExecContext(ctx, upsertNotificationsSettings, value) + return err +} + const upsertOAuthSigningKey = `-- name: UpsertOAuthSigningKey :exec INSERT INTO site_configs (key, value) VALUES ('oauth_signing_key', $1) ON CONFLICT (key) DO UPDATE set value = $1 WHERE site_configs.key = 'oauth_signing_key' diff --git a/coderd/database/queries/siteconfig.sql b/coderd/database/queries/siteconfig.sql index 2b56a6d1455af..9287a4aee0b54 100644 --- a/coderd/database/queries/siteconfig.sql +++ b/coderd/database/queries/siteconfig.sql @@ -79,3 +79,13 @@ SELECT -- name: UpsertHealthSettings :exec INSERT INTO site_configs (key, value) VALUES ('health_settings', $1) ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'health_settings'; + +-- name: GetNotificationsSettings :one +SELECT + COALESCE((SELECT value FROM site_configs WHERE key = 'notifications_settings'), '{}') :: text AS notifications_settings +; + +-- name: UpsertNotificationsSettings :exec +INSERT INTO site_configs (key, value) VALUES ('notifications_settings', $1) +ON CONFLICT (key) DO UPDATE SET value = $1 WHERE site_configs.key = 'notifications_settings'; + diff --git a/coderd/database/types.go b/coderd/database/types.go index fd7a2fed82300..7113b09e14a70 100644 --- a/coderd/database/types.go +++ b/coderd/database/types.go @@ -30,6 +30,11 @@ type HealthSettings struct { DismissedHealthchecks []healthsdk.HealthSection `db:"dismissed_healthchecks" json:"dismissed_healthchecks"` } +type NotificationsSettings struct { + ID uuid.UUID `db:"id" json:"id"` + NotifierPaused bool `db:"notifier_paused" json:"notifier_paused"` +} + type Actions []policy.Action func (a *Actions) Scan(src interface{}) error { diff --git a/coderd/notifications.go b/coderd/notifications.go new file mode 100644 index 0000000000000..290f7e8c0c0d6 --- /dev/null +++ b/coderd/notifications.go @@ -0,0 +1,121 @@ +package coderd + +import ( + "bytes" + "encoding/json" + "net/http" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/audit" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/codersdk" +) + +// @Summary Get notifications settings +// @ID get-notifications-settings +// @Security CoderSessionToken +// @Produce json +// @Tags General +// @Success 200 {object} codersdk.NotificationsSettings +// @Router /notifications/settings [get] +func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { + settingsJSON, err := api.Database.GetNotificationsSettings(r.Context()) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to fetch current notification settings.", + Detail: err.Error(), + }) + return + } + + var settings codersdk.NotificationsSettings + if len(settingsJSON) > 0 { + err = json.Unmarshal([]byte(settingsJSON), &settings) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to unmarshal notifications settings.", + Detail: err.Error(), + }) + return + } + } + httpapi.Write(r.Context(), rw, http.StatusOK, settings) +} + +// @Summary Update notifications settings +// @ID update-notifications-settings +// @Security CoderSessionToken +// @Accept json +// @Produce json +// @Tags General +// @Param request body codersdk.NotificationsSettings true "Notification settings request" +// @Success 200 {object} codersdk.NotificationsSettings +// @Router /notifications/settings [put] +func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + if !api.Authorize(r, policy.ActionUpdate, rbac.ResourceDeploymentConfig) { + httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{ + Message: "Insufficient permissions to update notifications settings.", + }) + return + } + + var settings codersdk.NotificationsSettings + if !httpapi.Read(ctx, rw, r, &settings) { + return + } + + settingsJSON, err := json.Marshal(&settings) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to marshal notifications settings.", + Detail: err.Error(), + }) + return + } + + currentSettingsJSON, err := api.Database.GetNotificationsSettings(r.Context()) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to fetch current notification settings.", + Detail: err.Error(), + }) + return + } + + if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) { + // See: https://www.rfc-editor.org/rfc/rfc7231#section-6.3.5 + httpapi.Write(r.Context(), rw, http.StatusNoContent, nil) + return + } + + auditor := api.Auditor.Load() + aReq, commitAudit := audit.InitRequest[database.NotificationsSettings](rw, &audit.RequestParams{ + Audit: *auditor, + Log: api.Logger, + Request: r, + Action: database.AuditActionWrite, + }) + defer commitAudit() + + aReq.New = database.NotificationsSettings{ + ID: uuid.New(), + NotifierPaused: settings.NotifierPaused, + } + + err = api.Database.UpsertNotificationsSettings(ctx, string(settingsJSON)) + if err != nil { + httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to update notifications settings.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(r.Context(), rw, http.StatusOK, settings) +} diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index d0d6355f0c68c..36c59317143ca 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/coder/serpent" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -15,6 +14,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/serpent" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 6c2cf430fe460..53e5dbd76c5c4 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -517,6 +517,113 @@ func TestInvalidConfig(t *testing.T) { require.ErrorIs(t, err, notifications.ErrInvalidDispatchTimeout) } +func TestNotifierPaused(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + + ctx, logger, db := setup(t) + + // Mock server to simulate webhook endpoint. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + endpoint, err := url.Parse(server.URL) + require.NoError(t, err) + + // given + fetchInterval := time.Nanosecond // manager should process messages immediately + + cfg := defaultNotificationsConfig(database.NotificationMethodWebhook) + cfg.Webhook = codersdk.NotificationsWebhookConfig{ + Endpoint: *serpent.URLOf(endpoint), + } + cfg.FetchInterval = *serpent.DurationOf(&fetchInterval) + mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) + require.NoError(t, err) + t.Cleanup(func() { + assert.NoError(t, mgr.Stop(ctx)) + }) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) + + user := dbgen.User(t, db, database.User{ + Email: "bob@coder.com", + Username: "bob", + Name: "Robert McBobbington", + }) + + input := map[string]string{"a": "b"} + + // Pause notifier + settingsJSON, err := json.Marshal(&codersdk.NotificationsSettings{ + NotifierPaused: true, + }) + require.NoError(t, err) + err = db.UpsertNotificationsSettings(ctx, string(settingsJSON)) + require.NoError(t, err) + + // Start notification manager + mgr.Run(ctx) + + // when + // Enqueue a bunch of messages + messagesCount := 50 + var wg sync.WaitGroup + wg.Add(1) + go func() { + for i := 0; i < messagesCount; i++ { + _, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, input, "test") + if err != nil { + logger.Named("enqueuer-test").Error(ctx, "unable to enqueue message", slog.Error(err)) + return + } + } + wg.Done() + }() + + // then + // Wait until they are all stored in the database + require.Eventually(t, func() bool { + pendingMessages, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ + Status: database.NotificationMessageStatusPending, + Limit: int32(messagesCount), + }) + if err != nil { + return false + } + return len(pendingMessages) == messagesCount + }, testutil.WaitShort, testutil.IntervalFast) + + wg.Wait() + + // when + // Unpause notifier + settingsJSON, err = json.Marshal(&codersdk.NotificationsSettings{ + NotifierPaused: false, + }) + require.NoError(t, err) + err = db.UpsertNotificationsSettings(ctx, string(settingsJSON)) + require.NoError(t, err) + + // test + // Check if messages have been dispatched + require.Eventually(t, func() bool { + pendingMessages, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ + Status: database.NotificationMessageStatusPending, + Limit: int32(messagesCount), + }) + if err != nil { + return false + } + return len(pendingMessages) == 0 + }, testutil.WaitShort, testutil.IntervalFast) +} + type fakeHandler struct { mu sync.RWMutex diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index b214f8a77a070..22ba981f4eb33 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -71,10 +71,18 @@ func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failu default: } - // Call process() immediately (i.e. don't wait an initial tick). - err := n.process(ctx, success, failure) + // Check if notifier is not paused. + ok, err := n.ensureRunning(ctx) if err != nil { - n.log.Error(ctx, "failed to process messages", slog.Error(err)) + n.log.Error(ctx, "failed to check notifier state", slog.Error(err)) + } + + if ok { + // Call process() immediately (i.e. don't wait an initial tick). + err = n.process(ctx, success, failure) + if err != nil { + n.log.Error(ctx, "failed to process messages", slog.Error(err)) + } } // Shortcut to bail out quickly if stop() has been called or the context canceled. @@ -89,6 +97,25 @@ func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failu } } +// ensureRunning checks if notifier is not paused. +func (n *notifier) ensureRunning(ctx context.Context) (bool, error) { + n.log.Debug(ctx, "check if notifier is paused") + + settingsJSON, err := n.store.GetNotificationsSettings(ctx) + if err != nil { + return false, xerrors.Errorf("get notifications settings: %w", err) + } + + var settings codersdk.NotificationsSettings + if len(settingsJSON) > 0 { + err = json.Unmarshal([]byte(settingsJSON), &settings) + if err != nil { + return false, xerrors.Errorf("unmarshal notifications settings") + } + } + return !settings.NotifierPaused, nil +} + // process is responsible for coordinating the retrieval, processing, and delivery of messages. // Messages are dispatched concurrently, but they may block when success/failure channels are full. // diff --git a/coderd/notifications/spec.go b/coderd/notifications/spec.go index 63f6af7101d1b..bba0d4e183c5c 100644 --- a/coderd/notifications/spec.go +++ b/coderd/notifications/spec.go @@ -21,6 +21,7 @@ type Store interface { EnqueueNotificationMessage(ctx context.Context, arg database.EnqueueNotificationMessageParams) (database.NotificationMessage, error) FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) + GetNotificationsSettings(ctx context.Context) (string, error) } // Handler is responsible for preparing and delivering a notification by a given method. diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go new file mode 100644 index 0000000000000..6fc2537322001 --- /dev/null +++ b/coderd/notifications_test.go @@ -0,0 +1,79 @@ +package coderd_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" +) + +func TestUpdateNotificationsSettings(t *testing.T) { + t.Parallel() + + t.Run("Permissions denied", func(t *testing.T) { + t.Parallel() + + api := coderdtest.New(t, nil) + firstUser := coderdtest.CreateFirstUser(t, api) + anotherClient, _ := coderdtest.CreateAnotherUser(t, api, firstUser.OrganizationID) + + // given + expected := codersdk.NotificationsSettings{ + NotifierPaused: true, + } + + ctx := testutil.Context(t, testutil.WaitShort) + + // when + err := anotherClient.PutNotificationsSettings(ctx, expected) + + // then + require.Error(t, err) // Insufficient permissions to update notifications settings. + }) + + t.Run("Settings modified", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + // given + expected := codersdk.NotificationsSettings{ + NotifierPaused: true, + } + + ctx := testutil.Context(t, testutil.WaitShort) + + // when + err := client.PutNotificationsSettings(ctx, expected) + require.NoError(t, err) + + // then + actual, err := client.GetNotificationsSettings(ctx) + require.NoError(t, err) + require.Equal(t, expected, actual) + }) + + t.Run("Settings not modified", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // given + expected := codersdk.NotificationsSettings{ + NotifierPaused: false, + } + err := client.PutNotificationsSettings(ctx, expected) + require.NoError(t, err) + + // then + err = client.PutNotificationsSettings(ctx, expected) + require.Error(t, err) // Error: notifications settings not modified + }) +} diff --git a/codersdk/audit.go b/codersdk/audit.go index 683db5406c13f..75bfe6204c607 100644 --- a/codersdk/audit.go +++ b/codersdk/audit.go @@ -14,20 +14,21 @@ import ( type ResourceType string const ( - ResourceTypeTemplate ResourceType = "template" - ResourceTypeTemplateVersion ResourceType = "template_version" - ResourceTypeUser ResourceType = "user" - ResourceTypeWorkspace ResourceType = "workspace" - ResourceTypeWorkspaceBuild ResourceType = "workspace_build" - ResourceTypeGitSSHKey ResourceType = "git_ssh_key" - ResourceTypeAPIKey ResourceType = "api_key" - ResourceTypeGroup ResourceType = "group" - ResourceTypeLicense ResourceType = "license" - ResourceTypeConvertLogin ResourceType = "convert_login" - ResourceTypeHealthSettings ResourceType = "health_settings" - ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" - ResourceTypeOrganization ResourceType = "organization" - ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app" + ResourceTypeTemplate ResourceType = "template" + ResourceTypeTemplateVersion ResourceType = "template_version" + ResourceTypeUser ResourceType = "user" + ResourceTypeWorkspace ResourceType = "workspace" + ResourceTypeWorkspaceBuild ResourceType = "workspace_build" + ResourceTypeGitSSHKey ResourceType = "git_ssh_key" + ResourceTypeAPIKey ResourceType = "api_key" + ResourceTypeGroup ResourceType = "group" + ResourceTypeLicense ResourceType = "license" + ResourceTypeConvertLogin ResourceType = "convert_login" + ResourceTypeHealthSettings ResourceType = "health_settings" + ResourceTypeNotificationsSettings ResourceType = "notifications_settings" + ResourceTypeWorkspaceProxy ResourceType = "workspace_proxy" + ResourceTypeOrganization ResourceType = "organization" + ResourceTypeOAuth2ProviderApp ResourceType = "oauth2_provider_app" // nolint:gosec // This is not a secret. ResourceTypeOAuth2ProviderAppSecret ResourceType = "oauth2_provider_app_secret" ResourceTypeCustomRole ResourceType = "custom_role" @@ -64,6 +65,8 @@ func (r ResourceType) FriendlyString() string { return "organization" case ResourceTypeHealthSettings: return "health_settings" + case ResourceTypeNotificationsSettings: + return "notifications_settings" case ResourceTypeOAuth2ProviderApp: return "oauth2 app" case ResourceTypeOAuth2ProviderAppSecret: diff --git a/codersdk/notifications.go b/codersdk/notifications.go new file mode 100644 index 0000000000000..16b1d6fbb4ee0 --- /dev/null +++ b/codersdk/notifications.go @@ -0,0 +1,42 @@ +package codersdk + +import ( + "context" + "encoding/json" + "net/http" + + "golang.org/x/xerrors" +) + +type NotificationsSettings struct { + NotifierPaused bool `json:"notifier_paused"` +} + +func (c *Client) GetNotificationsSettings(ctx context.Context) (NotificationsSettings, error) { + res, err := c.Request(ctx, http.MethodGet, "/api/v2/notifications/settings", nil) + if err != nil { + return NotificationsSettings{}, err + } + defer res.Body.Close() + if res.StatusCode != http.StatusOK { + return NotificationsSettings{}, ReadBodyAsError(res) + } + var settings NotificationsSettings + return settings, json.NewDecoder(res.Body).Decode(&settings) +} + +func (c *Client) PutNotificationsSettings(ctx context.Context, settings NotificationsSettings) error { + res, err := c.Request(ctx, http.MethodPut, "/api/v2/notifications/settings", settings) + if err != nil { + return err + } + defer res.Body.Close() + + if res.StatusCode == http.StatusNoContent { + return xerrors.New("notifications settings not modified") + } + if res.StatusCode != http.StatusOK { + return ReadBodyAsError(res) + } + return nil +} diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 5f34e6bf475c4..f239589f9700a 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -18,6 +18,7 @@ We track the following resources: | GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| | HealthSettings
|
FieldTracked
dismissed_healthcheckstrue
idfalse
| | License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| NotificationsSettings
|
FieldTracked
idfalse
notifier_pausedtrue
| | OAuth2ProviderApp
|
FieldTracked
callback_urltrue
created_atfalse
icontrue
idfalse
nametrue
updated_atfalse
| | OAuth2ProviderAppSecret
|
FieldTracked
app_idfalse
created_atfalse
display_secretfalse
hashed_secretfalse
idfalse
last_used_atfalse
secret_prefixfalse
| | Organization
|
FieldTracked
created_atfalse
descriptiontrue
display_nametrue
icontrue
idfalse
is_defaulttrue
nametrue
updated_attrue
| diff --git a/docs/api/general.md b/docs/api/general.md index 8bd968c6b18ed..2c34c7fbcf31a 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -651,6 +651,83 @@ Status Code **200** To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Get notifications settings + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/notifications/settings \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /notifications/settings` + +### Example responses + +> 200 Response + +```json +{ + "notifier_paused": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + +## Update notifications settings + +### Code samples + +```shell +# Example request using curl +curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ + -H 'Content-Type: application/json' \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`PUT /notifications/settings` + +> Body parameter + +```json +{ + "notifier_paused": true +} +``` + +### Parameters + +| Name | In | Type | Required | Description | +| ------ | ---- | -------------------------------------------------------------------------- | -------- | ----------------------------- | +| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notification settings request | + +### Example responses + +> 200 Response + +```json +{ + "notifier_paused": true +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Update check ### Code samples diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 5e2eaf7b74784..fd0d4c87437d4 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3122,6 +3122,20 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `hello` | string | false | | The hostname identifying the SMTP server. | | `smarthost` | [serpent.HostPort](#serpenthostport) | false | | The intermediary SMTP host through which emails are sent (host:port). | +## codersdk.NotificationsSettings + +```json +{ + "notifier_paused": true +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------------- | ------- | -------- | ------------ | ----------- | +| `notifier_paused` | boolean | false | | | + ## codersdk.NotificationsWebhookConfig ```json @@ -4157,6 +4171,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `license` | | `convert_login` | | `health_settings` | +| `notifications_settings` | | `workspace_proxy` | | `organization` | | `oauth2_provider_app` | diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index ed52b5e921560..f2ec06701904d 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -213,6 +213,10 @@ var auditableResourcesTypes = map[any]map[string]Action{ "id": ActionIgnore, "dismissed_healthchecks": ActionTrack, }, + &database.NotificationsSettings{}: { + "id": ActionIgnore, + "notifier_paused": ActionTrack, + }, // TODO: track an ID here when the below ticket is completed: // https://github.com/coder/coder/pull/6012 &database.License{}: { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index ad142b41392d0..6a3ce9adaae82 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -709,6 +709,11 @@ export interface NotificationsEmailConfig { readonly hello: string; } +// From codersdk/notifications.go +export interface NotificationsSettings { + readonly notifier_paused: boolean; +} + // From codersdk/deployment.go export interface NotificationsWebhookConfig { readonly endpoint: string; @@ -2242,6 +2247,7 @@ export type ResourceType = | "group" | "health_settings" | "license" + | "notifications_settings" | "oauth2_provider_app" | "oauth2_provider_app_secret" | "organization" @@ -2259,6 +2265,7 @@ export const ResourceTypes: ResourceType[] = [ "group", "health_settings", "license", + "notifications_settings", "oauth2_provider_app", "oauth2_provider_app_secret", "organization", From b6338e5186ab0021860c723ce4ea00a9bf3c4b28 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 08:28:33 +0200 Subject: [PATCH 02/20] revert --- coderd/database/dbpurge/dbpurge_test.go | 4 ++-- coderd/notifications/notifications_test.go | 3 --- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/coderd/database/dbpurge/dbpurge_test.go b/coderd/database/dbpurge/dbpurge_test.go index 4429898266045..4705fb31eec81 100644 --- a/coderd/database/dbpurge/dbpurge_test.go +++ b/coderd/database/dbpurge/dbpurge_test.go @@ -205,8 +205,8 @@ func TestDeleteOldWorkspaceAgentLogs(t *testing.T) { } return !containsAgentLog(agentLogs, t.Name()) }, testutil.WaitShort, testutil.IntervalFast) - assert.NoError(t, err) - assert.NotContains(t, agentLogs, t.Name()) + require.NoError(t, err) + require.NotContains(t, agentLogs, t.Name()) }) t.Run("AgentConnectedSixDaysAgo_LogsValid", func(t *testing.T) { diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 53e5dbd76c5c4..0d006bec839e2 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -152,9 +152,6 @@ func TestWebhookDispatch(t *testing.T) { t.Parallel() // setup - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } ctx, logger, db := setup(t) sent := make(chan dispatch.WebhookPayload, 1) From 16c8950b432dde1086e6c9febf676ad2c2ef4085 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 08:29:55 +0200 Subject: [PATCH 03/20] migrate down --- .../migrations/000223_notifications_settings_audit.down.sql | 1 + 1 file changed, 1 insertion(+) diff --git a/coderd/database/migrations/000223_notifications_settings_audit.down.sql b/coderd/database/migrations/000223_notifications_settings_audit.down.sql index 362f597df0911..de5e2cb77a38d 100644 --- a/coderd/database/migrations/000223_notifications_settings_audit.down.sql +++ b/coderd/database/migrations/000223_notifications_settings_audit.down.sql @@ -1 +1,2 @@ -- Nothing to do +-- It's not possible to drop enum values from enum types, so the up migration has "IF NOT EXISTS". From edfff5eb35756ce5b5d9a77014ed5d726ba5e959 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 08:33:48 +0200 Subject: [PATCH 04/20] 304 --- coderd/notifications.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/coderd/notifications.go b/coderd/notifications.go index 290f7e8c0c0d6..1a4cac4991e27 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -89,8 +89,8 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request } if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) { - // See: https://www.rfc-editor.org/rfc/rfc7231#section-6.3.5 - httpapi.Write(r.Context(), rw, http.StatusNoContent, nil) + // See: https://www.rfc-editor.org/rfc/rfc7232#section-4.1 + httpapi.Write(r.Context(), rw, http.StatusNotModified, nil) return } From 89e139634286492c49d9b7676dc488bdeb166d17 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 08:35:17 +0200 Subject: [PATCH 05/20] Notifications settings --- coderd/apidoc/docs.go | 2 +- coderd/apidoc/swagger.json | 2 +- coderd/notifications.go | 6 +++--- docs/api/general.md | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index a832115ac49c0..9221056a4ec0d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1590,7 +1590,7 @@ const docTemplate = `{ "operationId": "update-notifications-settings", "parameters": [ { - "description": "Notification settings request", + "description": "Notifications settings request", "name": "request", "in": "body", "required": true, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index a61fc6fdb1352..5ebc6aeb48070 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1377,7 +1377,7 @@ "operationId": "update-notifications-settings", "parameters": [ { - "description": "Notification settings request", + "description": "Notifications settings request", "name": "request", "in": "body", "required": true, diff --git a/coderd/notifications.go b/coderd/notifications.go index 1a4cac4991e27..4bcfa5963ed54 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -26,7 +26,7 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { settingsJSON, err := api.Database.GetNotificationsSettings(r.Context()) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to fetch current notification settings.", + Message: "Failed to fetch current notifications settings.", Detail: err.Error(), }) return @@ -52,7 +52,7 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { // @Accept json // @Produce json // @Tags General -// @Param request body codersdk.NotificationsSettings true "Notification settings request" +// @Param request body codersdk.NotificationsSettings true "Notifications settings request" // @Success 200 {object} codersdk.NotificationsSettings // @Router /notifications/settings [put] func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request) { @@ -82,7 +82,7 @@ func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request currentSettingsJSON, err := api.Database.GetNotificationsSettings(r.Context()) if err != nil { httpapi.Write(r.Context(), rw, http.StatusInternalServerError, codersdk.Response{ - Message: "Failed to fetch current notification settings.", + Message: "Failed to fetch current notifications settings.", Detail: err.Error(), }) return diff --git a/docs/api/general.md b/docs/api/general.md index 2c34c7fbcf31a..429aef893cc75 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -706,9 +706,9 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ ### Parameters -| Name | In | Type | Required | Description | -| ------ | ---- | -------------------------------------------------------------------------- | -------- | ----------------------------- | -| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notification settings request | +| Name | In | Type | Required | Description | +| ------ | ---- | -------------------------------------------------------------------------- | -------- | ------------------------------ | +| `body` | body | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | true | Notifications settings request | ### Example responses From ab8a6c7249903941f0c5d8689d4061bb46eb39db Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 08:40:02 +0200 Subject: [PATCH 06/20] debug message --- coderd/notifications/notifier.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 22ba981f4eb33..97296eb269943 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -113,6 +113,10 @@ func (n *notifier) ensureRunning(ctx context.Context) (bool, error) { return false, xerrors.Errorf("unmarshal notifications settings") } } + + if settings.NotifierPaused { + n.log.Debug(ctx, "notifier is paused, notifications will not be delivered") + } return !settings.NotifierPaused, nil } From 25223663f7f4cc7dbdeaf113305ea428effe0f24 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 08:47:28 +0200 Subject: [PATCH 07/20] return true --- coderd/notifications/notifier.go | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 97296eb269943..d400b52166b78 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -74,7 +74,7 @@ func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failu // Check if notifier is not paused. ok, err := n.ensureRunning(ctx) if err != nil { - n.log.Error(ctx, "failed to check notifier state", slog.Error(err)) + n.log.Warn(ctx, "failed to check notifier state", slog.Error(err)) } if ok { @@ -107,11 +107,13 @@ func (n *notifier) ensureRunning(ctx context.Context) (bool, error) { } var settings codersdk.NotificationsSettings - if len(settingsJSON) > 0 { - err = json.Unmarshal([]byte(settingsJSON), &settings) - if err != nil { - return false, xerrors.Errorf("unmarshal notifications settings") - } + if len(settingsJSON) == 0 { + return true, nil // settings.NotifierPaused is false by default + } + + err = json.Unmarshal([]byte(settingsJSON), &settings) + if err != nil { + return false, xerrors.Errorf("unmarshal notifications settings") } if settings.NotifierPaused { From 7dfbd296d6b8976c0220f2dc2e6df145a9ae88e7 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 08:48:37 +0200 Subject: [PATCH 08/20] remove WillUsePostgres --- coderd/notifications/notifications_test.go | 4 ---- 1 file changed, 4 deletions(-) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 0d006bec839e2..4ff59a1b74a7f 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -517,10 +517,6 @@ func TestInvalidConfig(t *testing.T) { func TestNotifierPaused(t *testing.T) { t.Parallel() - if !dbtestutil.WillUsePostgres() { - t.Skip("This test requires postgres") - } - ctx, logger, db := setup(t) // Mock server to simulate webhook endpoint. From 6abf39fc2fcb273c30e3915f65028951ddc106c7 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 09:52:30 +0200 Subject: [PATCH 09/20] fix client 304 --- codersdk/notifications.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codersdk/notifications.go b/codersdk/notifications.go index 16b1d6fbb4ee0..ff2041b1bd1d5 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -32,7 +32,7 @@ func (c *Client) PutNotificationsSettings(ctx context.Context, settings Notifica } defer res.Body.Close() - if res.StatusCode == http.StatusNoContent { + if res.StatusCode == http.StatusNotModified { return xerrors.New("notifications settings not modified") } if res.StatusCode != http.StatusOK { From 341485fc52f80c37b4a4b62ac8096df80723d389 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 10:00:54 +0200 Subject: [PATCH 10/20] createSampleUser --- coderd/notifications/notifications_test.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 4ff59a1b74a7f..ff7ea682e49bd 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -544,12 +544,7 @@ func TestNotifierPaused(t *testing.T) { enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - user := dbgen.User(t, db, database.User{ - Email: "bob@coder.com", - Username: "bob", - Name: "Robert McBobbington", - }) - + user := createSampleUser(t, db) input := map[string]string{"a": "b"} // Pause notifier From c90ff214d2fc58562b5a7d77403bc07841768aa3 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 10:08:58 +0200 Subject: [PATCH 11/20] sdkError --- coderd/notifications_test.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 6fc2537322001..e3e88df24db78 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -1,6 +1,7 @@ package coderd_test import ( + "net/http" "testing" "github.com/stretchr/testify/require" @@ -31,7 +32,10 @@ func TestUpdateNotificationsSettings(t *testing.T) { err := anotherClient.PutNotificationsSettings(ctx, expected) // then - require.Error(t, err) // Insufficient permissions to update notifications settings. + var sdkError *codersdk.Error + require.Error(t, err) + require.ErrorAsf(t, err, &sdkError, "error should be of type *codersdk.Error") + require.Equal(t, http.StatusForbidden, sdkError.StatusCode()) }) t.Run("Settings modified", func(t *testing.T) { From da6775f8c4543ac63371265cd869e5514257eece Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 10:40:47 +0200 Subject: [PATCH 12/20] remove test for not modified --- coderd/notifications_test.go | 20 -------------------- codersdk/notifications.go | 4 +--- 2 files changed, 1 insertion(+), 23 deletions(-) diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index e3e88df24db78..090e0005991f0 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -60,24 +60,4 @@ func TestUpdateNotificationsSettings(t *testing.T) { require.NoError(t, err) require.Equal(t, expected, actual) }) - - t.Run("Settings not modified", func(t *testing.T) { - t.Parallel() - - client := coderdtest.New(t, nil) - _ = coderdtest.CreateFirstUser(t, client) - - ctx := testutil.Context(t, testutil.WaitShort) - - // given - expected := codersdk.NotificationsSettings{ - NotifierPaused: false, - } - err := client.PutNotificationsSettings(ctx, expected) - require.NoError(t, err) - - // then - err = client.PutNotificationsSettings(ctx, expected) - require.Error(t, err) // Error: notifications settings not modified - }) } diff --git a/codersdk/notifications.go b/codersdk/notifications.go index ff2041b1bd1d5..58829eed57891 100644 --- a/codersdk/notifications.go +++ b/codersdk/notifications.go @@ -4,8 +4,6 @@ import ( "context" "encoding/json" "net/http" - - "golang.org/x/xerrors" ) type NotificationsSettings struct { @@ -33,7 +31,7 @@ func (c *Client) PutNotificationsSettings(ctx context.Context, settings Notifica defer res.Body.Close() if res.StatusCode == http.StatusNotModified { - return xerrors.New("notifications settings not modified") + return nil } if res.StatusCode != http.StatusOK { return ReadBodyAsError(res) From 9d430bbf283671707cf8032efd1a7c4e4ab59727 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 10:49:47 +0200 Subject: [PATCH 13/20] swagger update --- coderd/apidoc/docs.go | 3 +++ coderd/apidoc/swagger.json | 3 +++ coderd/notifications.go | 1 + docs/api/general.md | 7 ++++--- 4 files changed, 11 insertions(+), 3 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9221056a4ec0d..6554372157207 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -1605,6 +1605,9 @@ const docTemplate = `{ "schema": { "$ref": "#/definitions/codersdk.NotificationsSettings" } + }, + "304": { + "description": "Not Modified" } } } diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5ebc6aeb48070..03b0ba7716e2b 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -1392,6 +1392,9 @@ "schema": { "$ref": "#/definitions/codersdk.NotificationsSettings" } + }, + "304": { + "description": "Not Modified" } } } diff --git a/coderd/notifications.go b/coderd/notifications.go index 4bcfa5963ed54..f6bcbe0c7183d 100644 --- a/coderd/notifications.go +++ b/coderd/notifications.go @@ -54,6 +54,7 @@ func (api *API) notificationsSettings(rw http.ResponseWriter, r *http.Request) { // @Tags General // @Param request body codersdk.NotificationsSettings true "Notifications settings request" // @Success 200 {object} codersdk.NotificationsSettings +// @Success 304 // @Router /notifications/settings [put] func (api *API) putNotificationsSettings(rw http.ResponseWriter, r *http.Request) { ctx := r.Context() diff --git a/docs/api/general.md b/docs/api/general.md index 429aef893cc75..c628604b92123 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -722,9 +722,10 @@ curl -X PUT http://coder-server:8080/api/v2/notifications/settings \ ### Responses -| Status | Meaning | Description | Schema | -| ------ | ------------------------------------------------------- | ----------- | -------------------------------------------------------------------------- | -| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | +| Status | Meaning | Description | Schema | +| ------ | --------------------------------------------------------------- | ------------ | -------------------------------------------------------------------------- | +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.NotificationsSettings](schemas.md#codersdknotificationssettings) | +| 304 | [Not Modified](https://tools.ietf.org/html/rfc7232#section-4.1) | Not Modified | | To perform this operation, you must be authenticated. [Learn more](authentication.md). From a55fc8a8ad55e6d5c66f0f84784c633a814cb3da Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 11:01:40 +0200 Subject: [PATCH 14/20] bring back Settings not modified --- coderd/notifications_test.go | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 090e0005991f0..cedd440b043da 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -60,4 +60,22 @@ func TestUpdateNotificationsSettings(t *testing.T) { require.NoError(t, err) require.Equal(t, expected, actual) }) + + t.Run("Settings not modified", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + _ = coderdtest.CreateFirstUser(t, client) + + ctx := testutil.Context(t, testutil.WaitShort) + + // given + expected := codersdk.NotificationsSettings{ + NotifierPaused: false, + } + + // then + err := client.PutNotificationsSettings(ctx, expected) + require.NoError(t, err) + }) } From af36afb426c075b20496b9b925f9d23897a274f0 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 11:08:37 +0200 Subject: [PATCH 15/20] enable WillUsePostgres in TestWebhookDispatch --- coderd/notifications/notifications_test.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index ff7ea682e49bd..5ab31a225b9fa 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -152,6 +152,9 @@ func TestWebhookDispatch(t *testing.T) { t.Parallel() // setup + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } ctx, logger, db := setup(t) sent := make(chan dispatch.WebhookPayload, 1) @@ -620,7 +623,7 @@ type fakeHandler struct { } func (f *fakeHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) { - return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { + return func(_ context.Context, msgID uuid.UUID) (retryable bool, err error) { f.mu.Lock() defer f.mu.Unlock() From 7e8ad0943c8a3e59709e0efee0b779dac7762bc6 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 11:21:48 +0200 Subject: [PATCH 16/20] refactor Settings not modified --- coderd/notifications_test.go | 28 +++++++++++++++++++++------- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index cedd440b043da..7690154a0db80 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -64,18 +64,32 @@ func TestUpdateNotificationsSettings(t *testing.T) { t.Run("Settings not modified", func(t *testing.T) { t.Parallel() + // Empty state: notifications Settings are undefined now (default). client := coderdtest.New(t, nil) _ = coderdtest.CreateFirstUser(t, client) - ctx := testutil.Context(t, testutil.WaitShort) - // given - expected := codersdk.NotificationsSettings{ - NotifierPaused: false, - } + // Change the state: pause notifications + err := client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{ + NotifierPaused: true, + }) + require.NoError(t, err) - // then - err := client.PutNotificationsSettings(ctx, expected) + // Verify the state: notifications are paused. + actual, err := client.GetNotificationsSettings(ctx) + require.NoError(t, err) + require.True(t, actual.NotifierPaused) + + // Change the stage again: notifications are paused. + expected := actual + err = client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{ + NotifierPaused: true, + }) + require.NoError(t, err) + + // Verify the state: notifications are still paused, and there is no error returned. + actual, err = client.GetNotificationsSettings(ctx) require.NoError(t, err) + require.Equal(t, expected.NotifierPaused, actual.NotifierPaused) }) } From ce1a9e927cb0ffb12ce511c3b7d4204981dea14e Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 12:52:47 +0200 Subject: [PATCH 17/20] refactored TestNotifierPaused --- coderd/notifications/notifications_test.go | 96 ++++++++-------------- 1 file changed, 34 insertions(+), 62 deletions(-) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 5ab31a225b9fa..a9d59cabd39bd 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -520,98 +520,70 @@ func TestInvalidConfig(t *testing.T) { func TestNotifierPaused(t *testing.T) { t.Parallel() - ctx, logger, db := setup(t) - - // Mock server to simulate webhook endpoint. - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.WriteHeader(http.StatusNoContent) - })) - defer server.Close() - - endpoint, err := url.Parse(server.URL) - require.NoError(t, err) + // setup + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + t.Cleanup(cancel) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + db := dbmem.New() // FIXME https://github.com/coder/coder/pull/13863 - // given - fetchInterval := time.Nanosecond // manager should process messages immediately + // Prepare the test + handler := &fakeHandler{} + method := database.NotificationMethodSmtp + user := createSampleUser(t, db) - cfg := defaultNotificationsConfig(database.NotificationMethodWebhook) - cfg.Webhook = codersdk.NotificationsWebhookConfig{ - Endpoint: *serpent.URLOf(endpoint), - } + cfg := defaultNotificationsConfig(method) + fetchInterval := time.Nanosecond // Let cfg.FetchInterval = *serpent.DurationOf(&fetchInterval) mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) require.NoError(t, err) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) t.Cleanup(func() { assert.NoError(t, mgr.Stop(ctx)) }) enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - user := createSampleUser(t, db) - input := map[string]string{"a": "b"} + mgr.Run(ctx) - // Pause notifier - settingsJSON, err := json.Marshal(&codersdk.NotificationsSettings{ - NotifierPaused: true, - }) + // Notifier is on, enqueue the first message. + sid, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "success"}, "test") + require.NoError(t, err) + require.Eventually(t, func() bool { + handler.mu.RLock() + defer handler.mu.RUnlock() + return handler.succeeded == sid.String() + }, testutil.WaitShort, testutil.IntervalFast) + + // Pause the notifier. + settingsJSON, err := json.Marshal(&codersdk.NotificationsSettings{NotifierPaused: true}) require.NoError(t, err) err = db.UpsertNotificationsSettings(ctx, string(settingsJSON)) require.NoError(t, err) - // Start notification manager - mgr.Run(ctx) - - // when - // Enqueue a bunch of messages - messagesCount := 50 - var wg sync.WaitGroup - wg.Add(1) - go func() { - for i := 0; i < messagesCount; i++ { - _, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, input, "test") - if err != nil { - logger.Named("enqueuer-test").Error(ctx, "unable to enqueue message", slog.Error(err)) - return - } - } - wg.Done() - }() - - // then - // Wait until they are all stored in the database + // Notifier is paused, enqueue the next message. + sid, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "success"}, "test") + require.NoError(t, err) require.Eventually(t, func() bool { pendingMessages, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ Status: database.NotificationMessageStatusPending, - Limit: int32(messagesCount), }) if err != nil { return false } - return len(pendingMessages) == messagesCount + return len(pendingMessages) == 1 }, testutil.WaitShort, testutil.IntervalFast) - wg.Wait() - - // when - // Unpause notifier - settingsJSON, err = json.Marshal(&codersdk.NotificationsSettings{ - NotifierPaused: false, - }) + // Unpause the notifier. + settingsJSON, err = json.Marshal(&codersdk.NotificationsSettings{NotifierPaused: false}) require.NoError(t, err) err = db.UpsertNotificationsSettings(ctx, string(settingsJSON)) require.NoError(t, err) - // test - // Check if messages have been dispatched + // Notifier is running again, message should be dequeued. require.Eventually(t, func() bool { - pendingMessages, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ - Status: database.NotificationMessageStatusPending, - Limit: int32(messagesCount), - }) - if err != nil { - return false - } - return len(pendingMessages) == 0 + handler.mu.RLock() + defer handler.mu.RUnlock() + return handler.succeeded == sid.String() }, testutil.WaitShort, testutil.IntervalFast) } From dbf6a4022caf6c5390c2bbf35440938ac0dcd272 Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 13:25:07 +0200 Subject: [PATCH 18/20] bug: RLock RUnlock --- coderd/database/dbmem/dbmem.go | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 494beec843cf1..07ce74070ec38 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -8669,8 +8669,8 @@ func (q *FakeQuerier) UpsertDefaultProxy(_ context.Context, arg database.UpsertD } func (q *FakeQuerier) UpsertHealthSettings(_ context.Context, data string) error { - q.mutex.RLock() - defer q.mutex.RUnlock() + q.mutex.Lock() + defer q.mutex.Unlock() q.healthSettings = []byte(data) return nil @@ -8718,16 +8718,16 @@ func (q *FakeQuerier) UpsertLastUpdateCheck(_ context.Context, data string) erro } func (q *FakeQuerier) UpsertLogoURL(_ context.Context, data string) error { - q.mutex.RLock() - defer q.mutex.RUnlock() + q.mutex.Lock() + defer q.mutex.Unlock() q.logoURL = data return nil } func (q *FakeQuerier) UpsertNotificationsSettings(_ context.Context, data string) error { - q.mutex.RLock() - defer q.mutex.RUnlock() + q.mutex.Lock() + defer q.mutex.Unlock() q.notificationsSettings = []byte(data) return nil From c2e181c3681f57484f0eab99a41ccad66308426a Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 15:12:43 +0200 Subject: [PATCH 19/20] Danny's review --- coderd/notifications/notifications_test.go | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index a9d59cabd39bd..85389f67cede5 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -521,8 +521,7 @@ func TestNotifierPaused(t *testing.T) { t.Parallel() // setup - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) - t.Cleanup(cancel) + ctx := testutil.Context(t, testutil.WaitLong) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) db := dbmem.New() // FIXME https://github.com/coder/coder/pull/13863 @@ -567,9 +566,7 @@ func TestNotifierPaused(t *testing.T) { pendingMessages, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ Status: database.NotificationMessageStatusPending, }) - if err != nil { - return false - } + assert.NoError(t, err) return len(pendingMessages) == 1 }, testutil.WaitShort, testutil.IntervalFast) From 83978e73c9bcea53223541932e32e2ad0233ea2a Mon Sep 17 00:00:00 2001 From: Marcin Tojek Date: Wed, 10 Jul 2024 15:58:10 +0200 Subject: [PATCH 20/20] fix --- coderd/notifications/manager_test.go | 3 ++- coderd/notifications/notifications_test.go | 8 +++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 1a6e8b8f047da..fe161cc2cd8f6 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,12 +7,13 @@ import ( "testing" "time" - "github.com/coder/serpent" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/serpent" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/notifications" diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index a2571c0fa00da..c38daa1531ecb 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -542,9 +542,7 @@ func TestNotifierPaused(t *testing.T) { t.Parallel() // setup - ctx := testutil.Context(t, testutil.WaitLong) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) - db := dbmem.New() // FIXME https://github.com/coder/coder/pull/13863 + ctx, logger, db := setupInMemory(t) // Prepare the test handler := &fakeHandler{} @@ -571,7 +569,7 @@ func TestNotifierPaused(t *testing.T) { require.Eventually(t, func() bool { handler.mu.RLock() defer handler.mu.RUnlock() - return handler.succeeded == sid.String() + return slices.Contains(handler.succeeded, sid.String()) }, testutil.WaitShort, testutil.IntervalFast) // Pause the notifier. @@ -601,7 +599,7 @@ func TestNotifierPaused(t *testing.T) { require.Eventually(t, func() bool { handler.mu.RLock() defer handler.mu.RUnlock() - return handler.succeeded == sid.String() + return slices.Contains(handler.succeeded, sid.String()) }, testutil.WaitShort, testutil.IntervalFast) } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy