mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat: add killswitch for notifications (#13794)
This commit is contained in:
Generated
+75
@@ -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",
|
||||
|
||||
Generated
+65
@@ -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",
|
||||
|
||||
@@ -20,6 +20,7 @@ type Auditable interface {
|
||||
database.WorkspaceProxy |
|
||||
database.AuditOAuthConvertState |
|
||||
database.HealthSettings |
|
||||
database.NotificationsSettings |
|
||||
database.OAuth2ProviderApp |
|
||||
database.OAuth2ProviderAppSecret |
|
||||
database.CustomRole |
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+2
-1
@@ -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';
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user