feat: add killswitch for notifications (#13794)

This commit is contained in:
Marcin Tojek
2024-07-10 16:15:06 +02:00
committed by GitHub
parent 542fff7df0
commit bf392ffea4
31 changed files with 774 additions and 25 deletions
+75
View File
@@ -1547,6 +1547,71 @@ 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": "Notifications settings request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.NotificationsSettings"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.NotificationsSettings"
}
},
"304": {
"description": "Not Modified"
}
}
}
},
"/oauth2-provider/apps": {
"get": {
"security": [
@@ -10009,6 +10074,14 @@ const docTemplate = `{
}
}
},
"codersdk.NotificationsSettings": {
"type": "object",
"properties": {
"notifier_paused": {
"type": "boolean"
}
}
},
"codersdk.NotificationsWebhookConfig": {
"type": "object",
"properties": {
@@ -11036,6 +11109,7 @@ const docTemplate = `{
"license",
"convert_login",
"health_settings",
"notifications_settings",
"workspace_proxy",
"organization",
"oauth2_provider_app",
@@ -11054,6 +11128,7 @@ const docTemplate = `{
"ResourceTypeLicense",
"ResourceTypeConvertLogin",
"ResourceTypeHealthSettings",
"ResourceTypeNotificationsSettings",
"ResourceTypeWorkspaceProxy",
"ResourceTypeOrganization",
"ResourceTypeOAuth2ProviderApp",
+65
View File
@@ -1344,6 +1344,61 @@
}
}
},
"/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": "Notifications settings request",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/codersdk.NotificationsSettings"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/codersdk.NotificationsSettings"
}
},
"304": {
"description": "Not Modified"
}
}
}
},
"/oauth2-provider/apps": {
"get": {
"security": [
@@ -8978,6 +9033,14 @@
}
}
},
"codersdk.NotificationsSettings": {
"type": "object",
"properties": {
"notifier_paused": {
"type": "boolean"
}
}
},
"codersdk.NotificationsWebhookConfig": {
"type": "object",
"properties": {
@@ -9958,6 +10021,7 @@
"license",
"convert_login",
"health_settings",
"notifications_settings",
"workspace_proxy",
"organization",
"oauth2_provider_app",
@@ -9976,6 +10040,7 @@
"ResourceTypeLicense",
"ResourceTypeConvertLogin",
"ResourceTypeHealthSettings",
"ResourceTypeNotificationsSettings",
"ResourceTypeWorkspaceProxy",
"ResourceTypeOrganization",
"ResourceTypeOAuth2ProviderApp",
+1
View File
@@ -20,6 +20,7 @@ type Auditable interface {
database.WorkspaceProxy |
database.AuditOAuthConvertState |
database.HealthSettings |
database.NotificationsSettings |
database.OAuth2ProviderApp |
database.OAuth2ProviderAppSecret |
database.CustomRole |
+10
View File
@@ -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:
+5
View File
@@ -1243,6 +1243,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 {
+12
View File
@@ -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
+6
View File
@@ -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()
}))
+24 -4
View File
@@ -199,6 +199,7 @@ type data struct {
lastUpdateCheck []byte
announcementBanners []byte
healthSettings []byte
notificationsSettings []byte
applicationName string
logoURL string
appSecurityKey string
@@ -2771,6 +2772,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()
@@ -8668,8 +8680,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
@@ -8717,13 +8729,21 @@ 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.Lock()
defer q.mutex.Unlock()
q.notificationsSettings = []byte(data)
return nil
}
func (q *FakeQuerier) UpsertOAuthSigningKey(_ context.Context, value string) error {
q.mutex.Lock()
defer q.mutex.Unlock()
+14
View File
@@ -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)
+29
View File
@@ -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()
+2 -1
View File
@@ -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 (
@@ -0,0 +1,2 @@
-- Nothing to do
-- It's not possible to drop enum values from enum types, so the up migration has "IF NOT EXISTS".
@@ -0,0 +1,2 @@
-- This has to be outside a transaction
ALTER TYPE resource_type ADD VALUE IF NOT EXISTS 'notifications_settings';
+4 -1
View File
@@ -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,
}
}
+2
View File
@@ -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)
+22
View File
@@ -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(&notifications_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'
+10
View File
@@ -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';
+5
View File
@@ -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 {
+122
View File
@@ -0,0 +1,122 @@
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 notifications 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 "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()
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 notifications settings.",
Detail: err.Error(),
})
return
}
if bytes.Equal(settingsJSON, []byte(currentSettingsJSON)) {
// See: https://www.rfc-editor.org/rfc/rfc7232#section-4.1
httpapi.Write(r.Context(), rw, http.StatusNotModified, 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)
}
+2 -1
View File
@@ -12,13 +12,14 @@ import (
"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"
"github.com/coder/coder/v2/coderd/notifications/dispatch"
"github.com/coder/coder/v2/coderd/notifications/types"
"github.com/coder/coder/v2/testutil"
"github.com/coder/serpent"
)
func TestBufferedUpdates(t *testing.T) {
+66 -1
View File
@@ -538,6 +538,71 @@ func TestInvalidConfig(t *testing.T) {
require.ErrorIs(t, err, notifications.ErrInvalidDispatchTimeout)
}
func TestNotifierPaused(t *testing.T) {
t.Parallel()
// setup
ctx, logger, db := setupInMemory(t)
// Prepare the test
handler := &fakeHandler{}
method := database.NotificationMethodSmtp
user := createSampleUser(t, db)
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)
mgr.Run(ctx)
// 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 slices.Contains(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)
// 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,
})
assert.NoError(t, err)
return len(pendingMessages) == 1
}, testutil.WaitShort, testutil.IntervalFast)
// 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)
// Notifier is running again, message should be dequeued.
require.Eventually(t, func() bool {
handler.mu.RLock()
defer handler.mu.RUnlock()
return slices.Contains(handler.succeeded, sid.String())
}, testutil.WaitShort, testutil.IntervalFast)
}
type fakeHandler struct {
mu sync.RWMutex
@@ -546,7 +611,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()
+36 -3
View File
@@ -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.Warn(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,31 @@ 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 {
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 {
n.log.Debug(ctx, "notifier is paused, notifications will not be delivered")
}
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.
//
+1
View File
@@ -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.
+95
View File
@@ -0,0 +1,95 @@
package coderd_test
import (
"net/http"
"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
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) {
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()
// Empty state: notifications Settings are undefined now (default).
client := coderdtest.New(t, nil)
_ = coderdtest.CreateFirstUser(t, client)
ctx := testutil.Context(t, testutil.WaitShort)
// Change the state: pause notifications
err := client.PutNotificationsSettings(ctx, codersdk.NotificationsSettings{
NotifierPaused: true,
})
require.NoError(t, err)
// 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)
})
}