From 53c9cbb2d61d23baebae9220c7cf8e8c5a6b7208 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 11 Jun 2024 12:16:45 +0200 Subject: [PATCH 01/26] feat: system-generated notifications --- .golangci.yaml | 5 + cli/server.go | 52 +- coderd/apidoc/docs.go | 79 +++ coderd/apidoc/swagger.json | 83 ++- coderd/database/dbmem/dbmem.go | 85 ++- coderd/notifications/dispatch/smtp.go | 338 ++++++++++++ .../notifications/dispatch/smtp/html.gotmpl | 43 ++ .../dispatch/smtp/plaintext.gotmpl | 5 + coderd/notifications/dispatch/spec.go | 13 + coderd/notifications/dispatch/webhook.go | 121 ++++ coderd/notifications/events.go | 9 + coderd/notifications/manager.go | 450 +++++++++++++++ coderd/notifications/manager_test.go | 203 +++++++ coderd/notifications/noop.go | 21 + coderd/notifications/notifications_test.go | 521 ++++++++++++++++++ coderd/notifications/notifier.go | 236 ++++++++ coderd/notifications/provider.go | 44 ++ coderd/notifications/render/gotmpl.go | 26 + coderd/notifications/render/gotmpl_test.go | 59 ++ coderd/notifications/render/markdown.go | 113 ++++ coderd/notifications/singleton.go | 38 ++ coderd/notifications/spec.go | 36 ++ coderd/notifications/system/system.go | 19 + coderd/notifications/system/system_test.go | 46 ++ coderd/notifications/types/cta.go | 6 + coderd/notifications/types/labels.go | 72 +++ coderd/notifications/types/payload.go | 19 + .../provisionerdserver/provisionerdserver.go | 20 + codersdk/deployment.go | 218 ++++++++ docs/api/general.md | 35 ++ docs/api/schemas.md | 176 ++++++ docs/cli/server.md | 151 +++++ go.mod | 1 + go.sum | 2 + site/src/api/typesGenerated.ts | 33 +- 35 files changed, 3367 insertions(+), 11 deletions(-) create mode 100644 coderd/notifications/dispatch/smtp.go create mode 100644 coderd/notifications/dispatch/smtp/html.gotmpl create mode 100644 coderd/notifications/dispatch/smtp/plaintext.gotmpl create mode 100644 coderd/notifications/dispatch/spec.go create mode 100644 coderd/notifications/dispatch/webhook.go create mode 100644 coderd/notifications/events.go create mode 100644 coderd/notifications/manager.go create mode 100644 coderd/notifications/manager_test.go create mode 100644 coderd/notifications/noop.go create mode 100644 coderd/notifications/notifications_test.go create mode 100644 coderd/notifications/notifier.go create mode 100644 coderd/notifications/provider.go create mode 100644 coderd/notifications/render/gotmpl.go create mode 100644 coderd/notifications/render/gotmpl_test.go create mode 100644 coderd/notifications/render/markdown.go create mode 100644 coderd/notifications/singleton.go create mode 100644 coderd/notifications/spec.go create mode 100644 coderd/notifications/system/system.go create mode 100644 coderd/notifications/system/system_test.go create mode 100644 coderd/notifications/types/cta.go create mode 100644 coderd/notifications/types/labels.go create mode 100644 coderd/notifications/types/payload.go diff --git a/.golangci.yaml b/.golangci.yaml index f2ecce63da607..fd8946319ca1d 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -195,6 +195,11 @@ linters-settings: - name: var-naming - name: waitgroup-by-value + # irrelevant as of Go v1.22: https://go.dev/blog/loopvar-preview + govet: + disable: + - loopclosure + issues: # Rules listed here: https://github.com/securego/gosec#available-rules exclude-rules: diff --git a/cli/server.go b/cli/server.go index 79d2b132ad6e3..815db9e6f2bac 100644 --- a/cli/server.go +++ b/cli/server.go @@ -53,6 +53,8 @@ import ( "gopkg.in/yaml.v3" "tailscale.com/tailcfg" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/v2/buildinfo" @@ -73,6 +75,7 @@ import ( "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/gitsshkey" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/oauthpki" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/prometheusmetrics/insights" @@ -660,6 +663,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.OIDCConfig = oc } + experiments := coderd.ReadExperiments( + options.Logger, options.DeploymentValues.Experiments.Value(), + ) + // We'll read from this channel in the select below that tracks shutdown. If it remains // nil, that case of the select will just never fire, but it's important not to have a // "bare" read on this channel. @@ -969,6 +976,23 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. options.WorkspaceUsageTracker = tracker defer tracker.Close() + // Manage notifications. + var notificationsManager *notifications.Manager + if experiments.Enabled(codersdk.ExperimentNotifications) { + cfg := options.DeploymentValues.Notifications + nlog := logger.Named("notifications-manager") + notificationsManager, err = notifications.NewManager(cfg, options.Database, nlog, templateHelpers(options)) + if err != nil { + return xerrors.Errorf("failed to instantiate notification manager: %w", err) + } + + // nolint:gocritic // TODO: create own role. + notificationsManager.Run(dbauthz.AsSystemRestricted(ctx), int(cfg.WorkerCount.Value())) + notifications.RegisterInstance(notificationsManager) + } else { + notifications.RegisterInstance(notifications.NewNoopManager()) + } + // Wrap the server in middleware that redirects to the access URL if // the request is not to a local IP. var handler http.Handler = coderAPI.RootHandler @@ -1049,10 +1073,10 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. case <-stopCtx.Done(): exitErr = stopCtx.Err() waitForProvisionerJobs = true - _, _ = io.WriteString(inv.Stdout, cliui.Bold("Stop caught, waiting for provisioner jobs to complete and gracefully exiting. Use ctrl+\\ to force quit")) + _, _ = io.WriteString(inv.Stdout, cliui.Bold("Stop caught, waiting for provisioner jobs to complete and gracefully exiting. Use ctrl+\\ to force quit\n")) case <-interruptCtx.Done(): exitErr = interruptCtx.Err() - _, _ = io.WriteString(inv.Stdout, cliui.Bold("Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit")) + _, _ = io.WriteString(inv.Stdout, cliui.Bold("Interrupt caught, gracefully exiting. Use ctrl+\\ to force quit\n")) case <-tunnelDone: exitErr = xerrors.New("dev tunnel closed unexpectedly") case <-pubsubWatchdogTimeout: @@ -1088,6 +1112,21 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. // Cancel any remaining in-flight requests. shutdownConns() + if notificationsManager != nil { + // Stop the notification manager, which will cause any buffered updates to the store to be flushed. + // If the Stop() call times out, messages that were sent but not reflected as such in the store will have + // their leases expire after a period of time and will be re-queued for sending. + // See CODER_NOTIFICATIONS_LEASE_PERIOD. + cliui.Info(inv.Stdout, "Shutting down notifications manager..."+"\n") + err = shutdownWithTimeout(notificationsManager.Stop, 5*time.Second) + if err != nil { + cliui.Warnf(inv.Stderr, "Notifications manager shutdown took longer than 5s, "+ + "this may result in duplicate notifications being sent: %s\n", err) + } else { + cliui.Info(inv.Stdout, "Gracefully shut down notifications manager\n") + } + } + // Shut down provisioners before waiting for WebSockets // connections to close. var wg sync.WaitGroup @@ -1227,6 +1266,15 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return serverCmd } +// templateHelpers builds a set of functions which can be called in templates. +// We build them here to avoid an import cycle by using coderd.Options in notifications.Manager. +// We can later use this to inject whitelabel fields when app name / logo URL are overridden. +func templateHelpers(options *coderd.Options) map[string]any { + return map[string]any{ + "base_url": func() string { return options.AccessURL.String() }, + } +} + // printDeprecatedOptions loops through all command options, and prints // a warning for usage of deprecated options. func PrintDeprecatedOptions() serpent.MiddlewareFunc { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 0d923db69d8fc..fb3759d28370b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9200,6 +9200,9 @@ const docTemplate = `{ "metrics_cache_refresh_interval": { "type": "integer" }, + "notifications": { + "$ref": "#/definitions/codersdk.NotificationsConfig" + }, "oauth2": { "$ref": "#/definitions/codersdk.OAuth2Config" }, @@ -9378,6 +9381,7 @@ const docTemplate = `{ "multi-organization", "custom-roles", "workspace-usage" + "notifications" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", @@ -9392,6 +9396,7 @@ const docTemplate = `{ "ExperimentMultiOrganization", "ExperimentCustomRoles", "ExperimentWorkspaceUsage" + "ExperimentNotifications" ] }, "codersdk.ExternalAuth": { @@ -9925,6 +9930,80 @@ const docTemplate = `{ } } }, + "codersdk.NotificationsConfig": { + "type": "object", + "properties": { + "dispatch_timeout": { + "type": "integer" + }, + "email": { + "$ref": "#/definitions/codersdk.NotificationsEmailConfig" + }, + "fetch_interval": { + "type": "integer" + }, + "lease_count": { + "type": "integer" + }, + "lease_period": { + "type": "integer" + }, + "max_send_attempts": { + "description": "Retries.", + "type": "integer" + }, + "method": { + "description": "Dispatch.", + "type": "string" + }, + "retry_interval": { + "type": "integer" + }, + "sync_buffer_size": { + "type": "integer" + }, + "sync_interval": { + "description": "Store updates.", + "type": "integer" + }, + "webhook": { + "$ref": "#/definitions/codersdk.NotificationsWebhookConfig" + }, + "worker_count": { + "description": "Queue.", + "type": "integer" + } + } + }, + "codersdk.NotificationsEmailConfig": { + "type": "object", + "properties": { + "from": { + "description": "The sender's address.", + "type": "string" + }, + "hello": { + "description": "The hostname identifying the SMTP server.", + "type": "string" + }, + "smarthost": { + "description": "The intermediary SMTP host through which emails are sent (host:port).", + "allOf": [ + { + "$ref": "#/definitions/serpent.HostPort" + } + ] + } + } + }, + "codersdk.NotificationsWebhookConfig": { + "type": "object", + "properties": { + "endpoint": { + "$ref": "#/definitions/serpent.URL" + } + } + }, "codersdk.OAuth2AppEndpoints": { "type": "object", "properties": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 46caa7d6146da..ba17030e13770 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8220,6 +8220,9 @@ "metrics_cache_refresh_interval": { "type": "integer" }, + "notifications": { + "$ref": "#/definitions/codersdk.NotificationsConfig" + }, "oauth2": { "$ref": "#/definitions/codersdk.OAuth2Config" }, @@ -8393,7 +8396,8 @@ "auto-fill-parameters", "multi-organization", "custom-roles", - "workspace-usage" + "workspace-usage", + "notifications" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", @@ -8407,7 +8411,8 @@ "ExperimentAutoFillParameters", "ExperimentMultiOrganization", "ExperimentCustomRoles", - "ExperimentWorkspaceUsage" + "ExperimentWorkspaceUsage", + "ExperimentNotifications" ] }, "codersdk.ExternalAuth": { @@ -8894,6 +8899,80 @@ } } }, + "codersdk.NotificationsConfig": { + "type": "object", + "properties": { + "dispatch_timeout": { + "type": "integer" + }, + "email": { + "$ref": "#/definitions/codersdk.NotificationsEmailConfig" + }, + "fetch_interval": { + "type": "integer" + }, + "lease_count": { + "type": "integer" + }, + "lease_period": { + "type": "integer" + }, + "max_send_attempts": { + "description": "Retries.", + "type": "integer" + }, + "method": { + "description": "Dispatch.", + "type": "string" + }, + "retry_interval": { + "type": "integer" + }, + "sync_buffer_size": { + "type": "integer" + }, + "sync_interval": { + "description": "Store updates.", + "type": "integer" + }, + "webhook": { + "$ref": "#/definitions/codersdk.NotificationsWebhookConfig" + }, + "worker_count": { + "description": "Queue.", + "type": "integer" + } + } + }, + "codersdk.NotificationsEmailConfig": { + "type": "object", + "properties": { + "from": { + "description": "The sender's address.", + "type": "string" + }, + "hello": { + "description": "The hostname identifying the SMTP server.", + "type": "string" + }, + "smarthost": { + "description": "The intermediary SMTP host through which emails are sent (host:port).", + "allOf": [ + { + "$ref": "#/definitions/serpent.HostPort" + } + ] + } + } + }, + "codersdk.NotificationsWebhookConfig": { + "type": "object", + "properties": { + "endpoint": { + "$ref": "#/definitions/serpent.URL" + } + } + }, "codersdk.OAuth2AppEndpoints": { "type": "object", "properties": { diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 6258e69888aca..ddf2f2af5deaf 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -21,6 +21,8 @@ import ( "golang.org/x/exp/slices" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/notifications/types" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/rbac" @@ -62,6 +64,7 @@ func New() database.Store { auditLogs: make([]database.AuditLog, 0), files: make([]database.File, 0), gitSSHKey: make([]database.GitSSHKey, 0), + notificationMessages: make([]database.NotificationMessage, 0), parameterSchemas: make([]database.ParameterSchema, 0), provisionerDaemons: make([]database.ProvisionerDaemon, 0), workspaceAgents: make([]database.WorkspaceAgent, 0), @@ -156,6 +159,7 @@ type data struct { groups []database.Group jfrogXRayScans []database.JfrogXrayScan licenses []database.License + notificationMessages []database.NotificationMessage oauth2ProviderApps []database.OAuth2ProviderApp oauth2ProviderAppSecrets []database.OAuth2ProviderAppSecret oauth2ProviderAppCodes []database.OAuth2ProviderAppCode @@ -907,13 +911,45 @@ func (*FakeQuerier) AcquireLock(_ context.Context, _ int64) error { return xerrors.New("AcquireLock must only be called within a transaction") } -func (*FakeQuerier) AcquireNotificationMessages(_ context.Context, arg database.AcquireNotificationMessagesParams) ([]database.AcquireNotificationMessagesRow, error) { +// AcquireNotificationMessages implements the *basic* business logic, but is *not* exhaustive or meant to be 1:1 with +// the real AcquireNotificationMessages query. +func (q *FakeQuerier) AcquireNotificationMessages(_ context.Context, arg database.AcquireNotificationMessagesParams) ([]database.AcquireNotificationMessagesRow, error) { err := validateDatabaseType(arg) if err != nil { return nil, err } - // nolint:nilnil // Irrelevant. - return nil, nil + + q.mutex.Lock() + defer q.mutex.Unlock() + + var out []database.AcquireNotificationMessagesRow + for _, nm := range q.notificationMessages { + if len(out) >= int(arg.Count) { + break + } + + acquirableStatuses := []database.NotificationMessageStatus{database.NotificationMessageStatusPending, database.NotificationMessageStatusTemporaryFailure} + if !slices.Contains(acquirableStatuses, nm.Status) { + continue + } + + // Mimic mutation in database query. + nm.UpdatedAt = sql.NullTime{Time: time.Now(), Valid: true} + nm.Status = database.NotificationMessageStatusLeased + nm.StatusReason = sql.NullString{String: fmt.Sprintf("Enqueued by notifier %d", arg.NotifierID), Valid: true} + nm.LeasedUntil = sql.NullTime{Time: time.Now().Add(time.Second * time.Duration(arg.LeaseSeconds)), Valid: true} + + out = append(out, database.AcquireNotificationMessagesRow{ + ID: nm.ID, + Payload: nm.Payload, + Method: nm.Method, + CreatedBy: nm.CreatedBy, + TitleTemplate: "This is a title with {{.Labels.variable}}", + BodyTemplate: "This is a body with {{.Labels.variable}}", + }) + } + + return out, nil } func (q *FakeQuerier) AcquireProvisionerJob(_ context.Context, arg database.AcquireProvisionerJobParams) (database.ProvisionerJob, error) { @@ -1766,12 +1802,37 @@ func (q *FakeQuerier) DeleteWorkspaceAgentPortSharesByTemplate(_ context.Context return nil } -func (*FakeQuerier) EnqueueNotificationMessage(_ context.Context, arg database.EnqueueNotificationMessageParams) (database.NotificationMessage, error) { +func (q *FakeQuerier) EnqueueNotificationMessage(_ context.Context, arg database.EnqueueNotificationMessageParams) (database.NotificationMessage, error) { err := validateDatabaseType(arg) if err != nil { return database.NotificationMessage{}, err } - return database.NotificationMessage{}, nil + + q.mutex.Lock() + defer q.mutex.Unlock() + + var payload types.MessagePayload + err = json.Unmarshal(arg.Payload, &payload) + if err != nil { + return database.NotificationMessage{}, err + } + + nm := database.NotificationMessage{ + ID: arg.ID, + UserID: arg.UserID, + Method: arg.Method, + Payload: arg.Payload, + NotificationTemplateID: arg.NotificationTemplateID, + Targets: arg.Targets, + CreatedBy: arg.CreatedBy, + // Default fields. + CreatedAt: time.Now(), + Status: database.NotificationMessageStatusPending, + } + + q.notificationMessages = append(q.notificationMessages, nm) + + return nm, err } func (q *FakeQuerier) FavoriteWorkspace(_ context.Context, arg uuid.UUID) error { @@ -1798,7 +1859,19 @@ func (*FakeQuerier) FetchNewMessageMetadata(_ context.Context, arg database.Fetc if err != nil { return database.FetchNewMessageMetadataRow{}, err } - return database.FetchNewMessageMetadataRow{}, nil + + actions, err := json.Marshal([]types.TemplateAction{{URL: "http://xyz.com", Label: "XYZ"}}) + if err != nil { + return database.FetchNewMessageMetadataRow{}, err + } + + return database.FetchNewMessageMetadataRow{ + UserEmail: "test@test.com", + UserName: "Testy McTester", + NotificationName: "Some notification", + Actions: actions, + UserID: arg.UserID, + }, nil } func (q *FakeQuerier) GetAPIKeyByID(_ context.Context, id string) (database.APIKey, error) { diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go new file mode 100644 index 0000000000000..c870983d34e19 --- /dev/null +++ b/coderd/notifications/dispatch/smtp.go @@ -0,0 +1,338 @@ +package dispatch + +import ( + "bytes" + "context" + _ "embed" + "fmt" + "mime/multipart" + "mime/quotedprintable" + "net" + "net/mail" + "net/smtp" + "net/textproto" + "os" + "strings" + "time" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/notifications/render" + "github.com/coder/coder/v2/coderd/notifications/types" + "github.com/coder/coder/v2/codersdk" +) + +var ( + ValidationNoFromAddressErr = xerrors.New("no 'from' address defined") + ValidationNoSmarthostHostErr = xerrors.New("smarthost 'host' is not defined, or is invalid") + ValidationNoSmarthostPortErr = xerrors.New("smarthost 'port' is not defined, or is invalid") + ValidationNoHelloErr = xerrors.New("'hello' not defined") + + //go:embed smtp/html.gotmpl + htmlTemplate string + //go:embed smtp/plaintext.gotmpl + plainTemplate string +) + +// SMTPHandler is responsible for dispatching notification messages via SMTP. +// NOTE: auth and TLS is currently *not* enabled in this initial thin slice. +// TODO: implement auth +// TODO: implement TLS +type SMTPHandler struct { + cfg codersdk.NotificationsEmailConfig + log slog.Logger +} + +func NewSMTPHandler(cfg codersdk.NotificationsEmailConfig, log slog.Logger) *SMTPHandler { + return &SMTPHandler{cfg: cfg, log: log} +} + +func (*SMTPHandler) NotificationMethod() database.NotificationMethod { + return database.NotificationMethodSmtp +} + +func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string) (DeliveryFunc, error) { + // First render the subject & body into their own discrete strings. + subject, err := render.Plaintext(titleTmpl) + if err != nil { + return nil, xerrors.Errorf("render subject: %w", err) + } + + htmlBody, err := render.HTML(bodyTmpl) + if err != nil { + return nil, xerrors.Errorf("render HTML body: %w", err) + } + + plainBody, err := render.Plaintext(bodyTmpl) + if err != nil { + return nil, xerrors.Errorf("render plaintext body: %w", err) + } + + // Then, reuse these strings in the HTML & plain body templates. + payload.Labels.Set("_subject", subject) + payload.Labels.Set("_body", htmlBody) + htmlBody, err = render.GoTemplate(htmlTemplate, payload, nil) + if err != nil { + return nil, xerrors.Errorf("render full html template: %w", err) + } + payload.Labels.Set("_body", plainBody) + plainBody, err = render.GoTemplate(plainTemplate, payload, nil) + if err != nil { + return nil, xerrors.Errorf("render full plaintext template: %w", err) + } + + return s.dispatch(subject, htmlBody, plainBody, payload.UserEmail), nil +} + +// dispatch returns a DeliveryFunc capable of delivering a notification via SMTP. +// +// NOTE: this is heavily inspired by Alertmanager's email notifier: +// https://github.com/prometheus/alertmanager/blob/342f6a599ce16c138663f18ed0b880e777c3017d/notify/email/email.go +func (s *SMTPHandler) dispatch(subject, htmlBody, plainBody, to string) DeliveryFunc { + return func(ctx context.Context, msgID uuid.UUID) (bool, error) { + select { + case <-ctx.Done(): + return false, ctx.Err() + default: + } + + var ( + c *smtp.Client + conn net.Conn + err error + ) + + s.log.Debug(ctx, "dispatching via SMTP", slog.F("msg_id", msgID)) + + // Dial the smarthost to establish a connection. + smarthost, smarthostPort, err := s.smarthost() + if err != nil { + return false, xerrors.Errorf("'smarthost' validation: %w", err) + } + if smarthostPort == "465" { + return false, xerrors.New("TLS is not currently supported") + } + + var d net.Dialer + // Outer context has a deadline (see CODER_NOTIFICATIONS_DISPATCH_TIMEOUT). + conn, err = d.DialContext(ctx, "tcp", fmt.Sprintf("%s:%s", smarthost, smarthostPort)) + if err != nil { + return true, xerrors.Errorf("establish connection to server: %w", err) + } + + // Create an SMTP client. + c, err = smtp.NewClient(conn, smarthost) + if err != nil { + if cerr := conn.Close(); cerr != nil { + s.log.Warn(ctx, "failed to close connection", slog.Error(cerr)) + } + return true, xerrors.Errorf("create client: %w", err) + } + + // Cleanup. + defer func() { + if err := c.Quit(); err != nil { + s.log.Warn(ctx, "failed to close SMTP connection", slog.Error(err)) + } + }() + + // Server handshake. + hello, err := s.hello() + if err != nil { + return false, xerrors.Errorf("'hello' validation: %w", err) + } + err = c.Hello(hello) + if err != nil { + return false, xerrors.Errorf("server handshake: %w", err) + } + + // Check for authentication capabilities. + // if ok, mech := c.Extension("AUTH"); ok { + // auth, err := s.auth(mech) + // if err != nil { + // return true, xerrors.Errorf("find auth mechanism: %w", err) + // } + // if auth != nil { + // if err := c.Auth(auth); err != nil { + // return true, xerrors.Errorf("%T auth: %w", auth, err) + // } + // } + //} + + // Sender identification. + from, err := s.validateFromAddr(s.cfg.From.String()) + if err != nil { + return false, xerrors.Errorf("'from' validation: %w", err) + } + err = c.Mail(from) + if err != nil { + // This is retryable because the server may be temporarily down. + return true, xerrors.Errorf("sender identification: %w", err) + } + + // Recipient designation. + to, err := s.validateToAddrs(to) + if err != nil { + return false, xerrors.Errorf("'to' validation: %w", err) + } + for _, addr := range to { + err = c.Rcpt(addr) + if err != nil { + // This is a retryable case because the server may be temporarily down. + // The addresses are already validated, although it is possible that the server might disagree - in which case + // this will lead to some spurious retries, but that's not a big deal. + return true, xerrors.Errorf("recipient designation: %w", err) + } + } + + // Start message transmission. + message, err := c.Data() + if err != nil { + return true, xerrors.Errorf("message transmission: %w", err) + } + defer message.Close() + + // Transmit message headers. + msg := &bytes.Buffer{} + multipartBuffer := &bytes.Buffer{} + multipartWriter := multipart.NewWriter(multipartBuffer) + _, _ = fmt.Fprintf(msg, "From: %s\r\n", from) + _, _ = fmt.Fprintf(msg, "To: %s\r\n", strings.Join(to, ", ")) + _, _ = fmt.Fprintf(msg, "Subject: %s\r\n", subject) + _, _ = fmt.Fprintf(msg, "Message-Id: %s@%s\r\n", msgID, s.hostname()) + _, _ = fmt.Fprintf(msg, "Date: %s\r\n", time.Now().Format(time.RFC1123Z)) + _, _ = fmt.Fprintf(msg, "Content-Type: multipart/alternative; boundary=%s\r\n", multipartWriter.Boundary()) + _, _ = fmt.Fprintf(msg, "MIME-Version: 1.0\r\n\r\n") + _, err = message.Write(msg.Bytes()) + if err != nil { + return false, xerrors.Errorf("write headers: %w", err) + } + + // Transmit message body. + + // Text body + w, err := multipartWriter.CreatePart(textproto.MIMEHeader{ + "Content-Transfer-Encoding": {"quoted-printable"}, + "Content-Type": {"text/plain; charset=UTF-8"}, + }) + if err != nil { + return false, xerrors.Errorf("create part for text body: %w", err) + } + qw := quotedprintable.NewWriter(w) + _, err = qw.Write([]byte(plainBody)) + if err != nil { + return true, xerrors.Errorf("write text part: %w", err) + } + err = qw.Close() + if err != nil { + return true, xerrors.Errorf("close text part: %w", err) + } + + // HTML body + // Preferred body placed last per section 5.1.4 of RFC 2046 + // https://www.ietf.org/rfc/rfc2046.txt + w, err = multipartWriter.CreatePart(textproto.MIMEHeader{ + "Content-Transfer-Encoding": {"quoted-printable"}, + "Content-Type": {"text/html; charset=UTF-8"}, + }) + if err != nil { + return false, xerrors.Errorf("create part for HTML body: %w", err) + } + qw = quotedprintable.NewWriter(w) + _, err = qw.Write([]byte(htmlBody)) + if err != nil { + return true, xerrors.Errorf("write HTML part: %w", err) + } + err = qw.Close() + if err != nil { + return true, xerrors.Errorf("close HTML part: %w", err) + } + + err = multipartWriter.Close() + if err != nil { + return false, xerrors.Errorf("close multipartWriter: %w", err) + } + + _, err = message.Write(multipartBuffer.Bytes()) + if err != nil { + return false, xerrors.Errorf("write body buffer: %w", err) + } + + // Returning false, nil indicates successful send (i.e. non-retryable non-error) + return false, nil + } +} + +// auth returns a value which implements the smtp.Auth based on the available auth mechanism. +// func (*SMTPHandler) auth(_ string) (smtp.Auth, error) { +// return nil, nil +//} + +func (*SMTPHandler) validateFromAddr(from string) (string, error) { + addrs, err := mail.ParseAddressList(from) + if err != nil { + return "", xerrors.Errorf("parse 'from' address: %w", err) + } + if len(addrs) != 1 { + return "", ValidationNoFromAddressErr + } + return from, nil +} + +func (*SMTPHandler) validateToAddrs(to string) ([]string, error) { + addrs, err := mail.ParseAddressList(to) + if err != nil { + return nil, xerrors.Errorf("parse 'to' addresses: %w", err) + } + if len(addrs) == 0 { + // The addresses can be non-zero but invalid. + return nil, xerrors.Errorf("no valid 'to' address(es) found, given %+v", to) + } + + var out []string + for _, addr := range addrs { + out = append(out, addr.Address) + } + + return out, nil +} + +// smarthost retrieves the host/port defined and validates them. +// Does not allow overriding. +// nolint:revive // documented. +func (s *SMTPHandler) smarthost() (string, string, error) { + host := s.cfg.Smarthost.Host + port := s.cfg.Smarthost.Port + + // We don't validate the contents themselves; this will be done by the underlying SMTP library. + if host == "" { + return "", "", ValidationNoSmarthostHostErr + } + if port == "" { + return "", "", ValidationNoSmarthostPortErr + } + + return host, port, nil +} + +// hello retrieves the hostname identifying the SMTP server. +// Does not allow overriding. +func (s *SMTPHandler) hello() (string, error) { + val := s.cfg.Hello.String() + if val == "" { + return "", ValidationNoHelloErr + } + return val, nil +} + +func (*SMTPHandler) hostname() string { + h, err := os.Hostname() + // If we can't get the hostname, we'll use localhost + if err != nil { + h = "localhost.localdomain" + } + return h +} diff --git a/coderd/notifications/dispatch/smtp/html.gotmpl b/coderd/notifications/dispatch/smtp/html.gotmpl new file mode 100644 index 0000000000000..fc34a701ecc61 --- /dev/null +++ b/coderd/notifications/dispatch/smtp/html.gotmpl @@ -0,0 +1,43 @@ + + + + + + {{ .Labels._subject }} + + +
+
+ + + + + + + + + + + + + + + + + +
+
+

{{ .Labels._subject }}

+ {{ .Labels._body }} + + {{ range $action := .Actions }} + {{ $action.Label }}
+ {{ end }} +
+
+ + © 2024 Coder. All rights reserved. +
+
+ + \ No newline at end of file diff --git a/coderd/notifications/dispatch/smtp/plaintext.gotmpl b/coderd/notifications/dispatch/smtp/plaintext.gotmpl new file mode 100644 index 0000000000000..ecc60611d04bd --- /dev/null +++ b/coderd/notifications/dispatch/smtp/plaintext.gotmpl @@ -0,0 +1,5 @@ +{{ .Labels._body }} + +{{ range $action := .Actions }} +{{ $action.Label }}: {{ $action.URL }} +{{ end }} \ No newline at end of file diff --git a/coderd/notifications/dispatch/spec.go b/coderd/notifications/dispatch/spec.go new file mode 100644 index 0000000000000..037a0ebb4a1bf --- /dev/null +++ b/coderd/notifications/dispatch/spec.go @@ -0,0 +1,13 @@ +package dispatch + +import ( + "context" + + "github.com/google/uuid" +) + +// DeliveryFunc delivers the notification. +// The first return param indicates whether a retry can be attempted (i.e. a temporary error), and the second returns +// any error that may have arisen. +// If (false, nil) is returned, that is considered a successful dispatch. +type DeliveryFunc func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) diff --git a/coderd/notifications/dispatch/webhook.go b/coderd/notifications/dispatch/webhook.go new file mode 100644 index 0000000000000..0bad248743ca2 --- /dev/null +++ b/coderd/notifications/dispatch/webhook.go @@ -0,0 +1,121 @@ +package dispatch + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/notifications/render" + "github.com/coder/coder/v2/coderd/notifications/types" + "github.com/coder/coder/v2/codersdk" +) + +// WebhookHandler dispatches notification messages via an HTTP POST webhook. +type WebhookHandler struct { + cfg codersdk.NotificationsWebhookConfig + log slog.Logger + + cl *http.Client +} + +// WebhookPayload describes the JSON payload to be delivered to the configured webhook endpoint. +type WebhookPayload struct { + Version string `json:"_version"` + MsgID uuid.UUID `json:"msg_id"` + Payload types.MessagePayload `json:"payload"` + Title string `json:"title"` + Body string `json:"body"` +} + +func NewWebhookHandler(cfg codersdk.NotificationsWebhookConfig, log slog.Logger) *WebhookHandler { + return &WebhookHandler{cfg: cfg, log: log, cl: &http.Client{}} +} + +func (*WebhookHandler) NotificationMethod() database.NotificationMethod { + // TODO: don't use database types + return database.NotificationMethodWebhook +} + +func (w *WebhookHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string) (DeliveryFunc, error) { + if w.cfg.Endpoint.String() == "" { + return nil, xerrors.New("webhook endpoint not defined") + } + + title, err := render.Plaintext(titleTmpl) + if err != nil { + return nil, xerrors.Errorf("render title: %w", err) + } + body, err := render.Plaintext(bodyTmpl) + if err != nil { + return nil, xerrors.Errorf("render body: %w", err) + } + + return w.dispatch(payload, title, body, w.cfg.Endpoint.String()), nil +} + +func (w *WebhookHandler) dispatch(msgPayload types.MessagePayload, title, body, endpoint string) DeliveryFunc { + return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { + // Prepare payload. + payload := WebhookPayload{ + Version: "1.0", + MsgID: msgID, + Title: title, + Body: body, + Payload: msgPayload, + } + m, err := json.Marshal(payload) + if err != nil { + return false, xerrors.Errorf("marshal payload: %v", err) + } + + // Prepare request. + // Outer context has a deadline (see CODER_NOTIFICATIONS_DISPATCH_TIMEOUT). + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewBuffer(m)) + if err != nil { + return false, xerrors.Errorf("create HTTP request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + // Send request. + resp, err := w.cl.Do(req) + if err != nil { + return true, xerrors.Errorf("failed to send HTTP request: %v", err) + } + defer resp.Body.Close() + + // Handle response. + if resp.StatusCode/100 > 2 { + // Body could be quite long here, let's grab the first 500B and hope it contains useful debug info. + var respBody []byte + respBody, err = abbreviatedRead(resp.Body, 500) + if err != nil && err != io.EOF { + return true, xerrors.Errorf("non-200 response (%d), read body: %w", resp.StatusCode, err) + } + w.log.Warn(ctx, "unsuccessful delivery", slog.F("status_code", resp.StatusCode), + slog.F("response", respBody), slog.F("msg_id", msgID)) + return true, xerrors.Errorf("non-200 response (%d)", resp.StatusCode) + } + + return false, nil + } +} + +func abbreviatedRead(r io.Reader, maxLen int) ([]byte, error) { + out, err := io.ReadAll(r) + if err != nil { + return out, err + } + + if len(out) > maxLen { + return out[:maxLen], nil + } + + return out, nil +} diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go new file mode 100644 index 0000000000000..8aae44be14c71 --- /dev/null +++ b/coderd/notifications/events.go @@ -0,0 +1,9 @@ +package notifications + +import "github.com/google/uuid" + +var ( + // Workspaces. + TemplateWorkspaceDeleted = uuid.MustParse("'f517da0b-cdc9-410f-ab89-a86107c420ed'") + // ... +) diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go new file mode 100644 index 0000000000000..ec7aef7cfce1a --- /dev/null +++ b/coderd/notifications/manager.go @@ -0,0 +1,450 @@ +package notifications + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/google/uuid" + "golang.org/x/sync/errgroup" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/notifications/render" + "github.com/coder/coder/v2/codersdk" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/notifications/dispatch" + "github.com/coder/coder/v2/coderd/notifications/types" + + "cdr.dev/slog" +) + +// Manager manages all notifications being enqueued and dispatched. +// +// Manager maintains a group of notifiers: these consume the queue of notification messages in the store. +// +// Notifiers dequeue messages from the store _CODER_NOTIFICATIONS_LEASE_COUNT_ at a time and concurrently "dispatch" these messages, meaning they are +// sent by their respective methods (email, webhook, etc). +// +// To reduce load on the store, successful and failed dispatches are accumulated in two separate buffers (success/failure) +// of size CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL in the Manager, and updates are sent to the store about which messages +// succeeded or failed every CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL seconds. +// These buffers are limited in size, and naturally introduce some backpressure; if there are hundreds of messages to be +// sent but they start failing too quickly, the buffers (receive channels) will fill up and block senders, which will +// slow down the dispatch rate. +// +// NOTE: The above backpressure mechanism only works if all notifiers live within the same process, which may not be true +// forever, such as if we split notifiers out into separate targets for greater processing throughput; in this case we +// will need an alternative mechanism for handling backpressure. +type Manager struct { + cfg codersdk.NotificationsConfig + // TODO: expand this to allow for each notification to have custom delivery methods, or multiple, or none. + // For example, Larry might want email notifications for "workspace deleted" notifications, but Harry wants + // Slack notifications, and Mary doesn't want any. + method database.NotificationMethod + + store Store + log slog.Logger + + notifiers []*notifier + notifierMu sync.Mutex + + handlers *HandlerRegistry + helpers map[string]any + + stopOnce sync.Once + stop chan any + done chan any +} + +// NewManager instantiates a new Manager instance which coordinates notification enqueuing and delivery. +// +// helpers is a map of template helpers which are used to customize notification messages to use global settings like +// access URL etc. +func NewManager(cfg codersdk.NotificationsConfig, store Store, log slog.Logger, helpers map[string]any) (*Manager, error) { + var method database.NotificationMethod + if err := method.Scan(cfg.Method.String()); err != nil { + return nil, xerrors.Errorf("given notification method %q is invalid", cfg.Method) + } + + return &Manager{ + log: log, + cfg: cfg, + store: store, + method: method, + + stop: make(chan any), + done: make(chan any), + + handlers: defaultHandlers(cfg, log), + helpers: helpers, + }, nil +} + +// defaultHandlers builds a set of known handlers; panics if any error occurs as these handlers should be valid at compile time. +func defaultHandlers(cfg codersdk.NotificationsConfig, log slog.Logger) *HandlerRegistry { + reg, err := NewHandlerRegistry( + dispatch.NewSMTPHandler(cfg.SMTP, log.Named("dispatcher.smtp")), + dispatch.NewWebhookHandler(cfg.Webhook, log.Named("dispatcher.webhook")), + ) + if err != nil { + panic(err) + } + return reg +} + +// WithHandlers allows for tests to inject their own handlers to verify functionality. +func (m *Manager) WithHandlers(reg *HandlerRegistry) { + m.handlers = reg +} + +// Run initiates the control loop in the background, which spawns a given number of notifier goroutines. +// Manager requires system-level permissions to interact with the store. +func (m *Manager) Run(ctx context.Context, notifiers int) { + // Closes when Stop() is called or context is canceled. + go func() { + err := m.loop(ctx, notifiers) + if err != nil { + m.log.Error(ctx, "notification manager stopped with error", slog.Error(err)) + } + }() +} + +// loop contains the main business logic of the notification manager. It is responsible for subscribing to notification +// events, creating notifiers, and publishing bulk dispatch result updates to the store. +func (m *Manager) loop(ctx context.Context, notifiers int) error { + defer func() { + close(m.done) + m.log.Info(context.Background(), "notification manager stopped") + }() + + // Caught a terminal signal before notifiers were created, exit immediately. + select { + case <-m.stop: + m.log.Warn(ctx, "gracefully stopped") + return xerrors.Errorf("gracefully stopped") + case <-ctx.Done(): + m.log.Error(ctx, "ungracefully stopped", slog.Error(ctx.Err())) + return xerrors.Errorf("notifications: %w", ctx.Err()) + default: + } + + var ( + // Buffer successful/failed notification dispatches in memory to reduce load on the store. + // + // We keep separate buffered for success/failure right now because the bulk updates are already a bit janky, + // see BulkMarkNotificationMessagesSent/BulkMarkNotificationMessagesFailed. If we had the ability to batch updates, + // like is offered in https://docs.sqlc.dev/en/stable/reference/query-annotations.html#batchmany, we'd have a cleaner + // approach to this - but for now this will work fine. + success = make(chan dispatchResult, m.cfg.StoreSyncBufferSize) + failure = make(chan dispatchResult, m.cfg.StoreSyncBufferSize) + ) + + // Create a specific number of notifiers to run concurrently. + var eg errgroup.Group + for i := 0; i < notifiers; i++ { + eg.Go(func() error { + m.notifierMu.Lock() + n := newNotifier(ctx, m.cfg, i+1, m.log, m.store, m.handlers) + m.notifiers = append(m.notifiers, n) + m.notifierMu.Unlock() + return n.run(ctx, success, failure) + }) + } + + eg.Go(func() error { + // Every interval, collect the messages in the channels and bulk update them in the database. + tick := time.NewTicker(m.cfg.StoreSyncInterval.Value()) + defer tick.Stop() + for { + select { + case <-ctx.Done(): + // Nothing we can do in this scenario except bail out; after the message lease expires, the messages will + // be requeued and users will receive duplicates. + // This is an explicit trade-off between keeping the database load light (by bulk-updating records) and + // exactly-once delivery. + // + // The current assumption is that duplicate delivery of these messages is, at worst, slightly annoying. + // If these notifications are triggering external actions (e.g. via webhooks) this could be more + // consequential, and we may need a more sophisticated mechanism. + // + // TODO: mention the above tradeoff in documentation. + m.log.Warn(ctx, "exiting ungracefully", slog.Error(ctx.Err())) + + if len(success)+len(failure) > 0 { + m.log.Warn(ctx, "content canceled with pending updates in buffer, these messages will be sent again after lease expires", + slog.F("success_count", len(success)), slog.F("failure_count", len(failure))) + } + return ctx.Err() + case <-m.stop: + if len(success)+len(failure) > 0 { + m.log.Warn(ctx, "flushing buffered updates before stop", + slog.F("success_count", len(success)), slog.F("failure_count", len(failure))) + m.bulkUpdate(ctx, success, failure) + m.log.Warn(ctx, "flushing updates done") + } + return nil + case <-tick.C: + m.bulkUpdate(ctx, success, failure) + } + } + }) + + err := eg.Wait() + if err != nil { + m.log.Error(ctx, "manager loop exited with error", slog.Error(err)) + } + return err +} + +// Enqueue queues a notification message for later delivery. +// Messages will be dequeued by a notifier later and dispatched. +func (m *Manager) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels types.Labels, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { + payload, err := m.buildPayload(ctx, userID, templateID, labels) + if err != nil { + m.log.Warn(ctx, "failed to build payload", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err)) + return nil, xerrors.Errorf("enqueue notification (payload build): %w", err) + } + + input, err := json.Marshal(payload) + if err != nil { + return nil, xerrors.Errorf("failed encoding input labels: %w", err) + } + + id := uuid.New() + msg, err := m.store.EnqueueNotificationMessage(ctx, database.EnqueueNotificationMessageParams{ + ID: id, + UserID: userID, + NotificationTemplateID: templateID, + Method: m.method, + Payload: input, + Targets: targets, + CreatedBy: createdBy, + }) + if err != nil { + m.log.Warn(ctx, "failed to enqueue notification", slog.F("template_id", templateID), slog.F("input", input), slog.Error(err)) + return nil, xerrors.Errorf("enqueue notification: %w", err) + } + + m.log.Debug(ctx, "enqueued notification", slog.F("msg_id", msg.ID)) + return &id, nil +} + +// buildPayload creates the payload that the notification will for variable substitution and/or routing. +// The payload contains information about the recipient, the event that triggered the notification, and any subsequent +// actions which can be taken by the recipient. +func (m *Manager) buildPayload(ctx context.Context, userID uuid.UUID, templateID uuid.UUID, labels types.Labels) (*types.MessagePayload, error) { + metadata, err := m.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ + UserID: userID, + NotificationTemplateID: templateID, + }) + if err != nil { + return nil, xerrors.Errorf("new message metadata: %w", err) + } + + // Execute any templates in actions. + out, err := render.GoTemplate(string(metadata.Actions), types.MessagePayload{}, m.helpers) + if err != nil { + return nil, xerrors.Errorf("render actions: %w", err) + } + metadata.Actions = []byte(out) + + var actions []types.TemplateAction + if err = json.Unmarshal(metadata.Actions, &actions); err != nil { + return nil, xerrors.Errorf("new message metadata: parse template actions: %w", err) + } + + return &types.MessagePayload{ + Version: "1.0", + + NotificationName: metadata.NotificationName, + + UserID: metadata.UserID.String(), + UserEmail: metadata.UserEmail, + UserName: metadata.UserName, + + Actions: actions, + Labels: labels, + }, nil +} + +// bulkUpdate updates messages in the store based on the given successful and failed message dispatch results. +func (m *Manager) bulkUpdate(ctx context.Context, success, failure <-chan dispatchResult) { + select { + case <-ctx.Done(): + return + default: + } + + nSuccess := len(success) + nFailure := len(failure) + + // Nothing to do. + if nSuccess+nFailure == 0 { + return + } + + var ( + successParams database.BulkMarkNotificationMessagesSentParams + failureParams database.BulkMarkNotificationMessagesFailedParams + ) + + // Read all the existing messages due for update from the channel, but don't range over the channels because they + // block until they are closed. + // + // This is vulnerable to TOCTOU, but it's fine. + // If more items are added to the success or failure channels between measuring their lengths and now, those items + // will be processed on the next bulk update. + + for i := 0; i < nSuccess; i++ { + res := <-success + successParams.IDs = append(successParams.IDs, res.msg) + successParams.SentAts = append(successParams.SentAts, res.ts) + } + for i := 0; i < nFailure; i++ { + res := <-failure + + status := database.NotificationMessageStatusPermanentFailure + if res.retryable { + status = database.NotificationMessageStatusTemporaryFailure + } + + failureParams.IDs = append(failureParams.IDs, res.msg) + failureParams.FailedAts = append(failureParams.FailedAts, res.ts) + failureParams.Statuses = append(failureParams.Statuses, status) + var reason string + if res.err != nil { + reason = res.err.Error() + } + failureParams.StatusReasons = append(failureParams.StatusReasons, reason) + } + + // Execute bulk updates for success/failure concurrently. + var wg sync.WaitGroup + wg.Add(2) + + go func() { + defer wg.Done() + if len(successParams.IDs) == 0 { + return + } + + logger := m.log.With(slog.F("type", "update_sent")) + + // Give up after waiting for the store for 30s. + uctx, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + + n, err := m.store.BulkMarkNotificationMessagesSent(uctx, successParams) + if err != nil { + logger.Error(ctx, "bulk update failed", slog.Error(err)) + return + } + + logger.Debug(ctx, "bulk update completed", slog.F("updated", n)) + }() + + go func() { + defer wg.Done() + if len(failureParams.IDs) == 0 { + return + } + + logger := m.log.With(slog.F("type", "update_failed")) + + // Give up after waiting for the store for 30s. + uctx, cancel := context.WithTimeout(ctx, time.Second*30) + defer cancel() + + failureParams.MaxAttempts = int32(m.cfg.MaxSendAttempts) + failureParams.RetryInterval = int32(m.cfg.RetryInterval.Value().Seconds()) + n, err := m.store.BulkMarkNotificationMessagesFailed(uctx, failureParams) + if err != nil { + logger.Error(ctx, "bulk update failed", slog.Error(err)) + return + } + + logger.Debug(ctx, "bulk update completed", slog.F("updated", n)) + }() + + wg.Wait() +} + +// Stop stops all notifiers and waits until they have stopped. +func (m *Manager) Stop(ctx context.Context) error { + var err error + m.stopOnce.Do(func() { + select { + case <-ctx.Done(): + err = ctx.Err() + return + default: + } + + m.log.Info(context.Background(), "graceful stop requested") + + // If the notifiers haven't been started, we don't need to wait for anything. + // This is only really during testing when we want to enqueue messages only but not deliver them. + if len(m.notifiers) == 0 { + close(m.done) + } + + // Stop all notifiers. + var eg errgroup.Group + for _, n := range m.notifiers { + eg.Go(func() error { + n.stop() + return nil + }) + } + _ = eg.Wait() + + // Signal the stop channel to cause loop to exit. + close(m.stop) + + // Wait for the manager loop to exit or the context to be canceled, whichever comes first. + select { + case <-ctx.Done(): + var errStr string + if ctx.Err() != nil { + errStr = ctx.Err().Error() + } + // For some reason, slog.Error returns {} for a context error. + m.log.Error(context.Background(), "graceful stop failed", slog.F("err", errStr)) + err = ctx.Err() + return + case <-m.done: + m.log.Info(context.Background(), "gracefully stopped") + return + } + }) + + return err +} + +type dispatchResult struct { + notifier int + msg uuid.UUID + ts time.Time + err error + retryable bool +} + +func newSuccessfulDispatch(notifier int, msg uuid.UUID) dispatchResult { + return dispatchResult{ + notifier: notifier, + msg: msg, + ts: time.Now(), + } +} + +func newFailedDispatch(notifier int, msg uuid.UUID, err error, retryable bool) dispatchResult { + return dispatchResult{ + notifier: notifier, + msg: msg, + ts: time.Now(), + err: err, + retryable: retryable, + } +} diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go new file mode 100644 index 0000000000000..1544b829eec41 --- /dev/null +++ b/coderd/notifications/manager_test.go @@ -0,0 +1,203 @@ +package notifications_test + +import ( + "context" + "encoding/json" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbmem" + "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" +) + +// TestSingletonRegistration tests that a Manager which has been instantiated but not registered will error. +func TestSingletonRegistration(t *testing.T) { + t.Parallel() + + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + + mgr, err := notifications.NewManager(defaultNotificationsConfig(), dbmem.New(), logger, defaultHelpers()) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, mgr.Stop(ctx)) + }) + + // Not registered yet. + _, err = notifications.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "") + require.ErrorIs(t, err, notifications.SingletonNotRegisteredErr) + + // Works after registering. + notifications.RegisterInstance(mgr) + _, err = notifications.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "") + require.NoError(t, err) +} + +func TestBufferedUpdates(t *testing.T) { + t.Parallel() + + // setup + ctx, logger, db, ps := setup(t) + interceptor := &bulkUpdateInterceptor{Store: db} + + santa := &santaHandler{} + handlers, err := notifications.NewHandlerRegistry(santa) + require.NoError(t, err) + mgr, err := notifications.NewManager(defaultNotificationsConfig(), interceptor, logger.Named("notifications"), defaultHelpers()) + require.NoError(t, err) + mgr.WithHandlers(handlers) + + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + user := coderdtest.CreateFirstUser(t, client) + + // given + if _, err := mgr.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { + require.NoError(t, err) + } + if _, err := mgr.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "false"}, ""); true { + require.NoError(t, err) + } + + // when + mgr.Run(ctx, 1) + + // then + + // Wait for messages to be dispatched. + require.Eventually(t, func() bool { return santa.naughty.Load() == 1 && santa.nice.Load() == 1 }, testutil.WaitMedium, testutil.IntervalFast) + + // Stop the manager which forces an update of buffered updates. + require.NoError(t, mgr.Stop(ctx)) + + // Wait until both success & failure updates have been sent to the store. + require.Eventually(t, func() bool { return interceptor.failed.Load() == 1 && interceptor.sent.Load() == 1 }, testutil.WaitMedium, testutil.IntervalFast) +} + +func TestBuildPayload(t *testing.T) { + // given + const label = "Click here!" + const url = "http://xyz.com/" + helpers := map[string]any{ + "my_label": func() string { return label }, + "my_url": func() string { return url }, + } + + ctx := context.Background() + db := dbmem.New() + interceptor := newEnqueueInterceptor(db, + // Inject custom message metadata to influence the payload construction. + func() database.FetchNewMessageMetadataRow { + // Inject template actions which use injected help functions. + actions := []types.TemplateAction{ + { + Label: "{{ my_label }}", + URL: "{{ my_url }}", + }, + } + out, err := json.Marshal(actions) + require.NoError(t, err) + + return database.FetchNewMessageMetadataRow{ + NotificationName: "My Notification", + Actions: out, + UserID: uuid.New(), + UserEmail: "bob@bob.com", + UserName: "bobby", + } + }) + + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + mgr, err := notifications.NewManager(defaultNotificationsConfig(), interceptor, logger.Named("notifications"), helpers) + require.NoError(t, err) + + // when + _, err = mgr.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "test") + require.NoError(t, err) + + // then + select { + case payload := <-interceptor.payload: + require.Len(t, payload.Actions, 1) + require.Equal(t, label, payload.Actions[0].Label) + require.Equal(t, url, payload.Actions[0].URL) + case <-time.After(testutil.WaitShort): + t.Fatalf("timed out") + } +} + +type bulkUpdateInterceptor struct { + notifications.Store + + sent atomic.Int32 + failed atomic.Int32 +} + +func (b *bulkUpdateInterceptor) BulkMarkNotificationMessagesSent(ctx context.Context, arg database.BulkMarkNotificationMessagesSentParams) (int64, error) { + b.sent.Add(int32(len(arg.IDs))) + return b.Store.BulkMarkNotificationMessagesSent(ctx, arg) +} + +func (b *bulkUpdateInterceptor) BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) { + b.failed.Add(int32(len(arg.IDs))) + return b.Store.BulkMarkNotificationMessagesFailed(ctx, arg) +} + +// santaHandler only dispatches nice messages. +type santaHandler struct { + naughty atomic.Int32 + nice atomic.Int32 +} + +func (*santaHandler) NotificationMethod() database.NotificationMethod { + return database.NotificationMethodSmtp +} + +func (s *santaHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) { + return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { + if payload.Labels.Get("nice") != "true" { + s.naughty.Add(1) + return false, xerrors.New("be nice") + } + + s.nice.Add(1) + return false, nil + }, nil +} + +type enqueueInterceptor struct { + notifications.Store + + payload chan types.MessagePayload + metadataFn func() database.FetchNewMessageMetadataRow +} + +func newEnqueueInterceptor(db notifications.Store, metadataFn func() database.FetchNewMessageMetadataRow) *enqueueInterceptor { + return &enqueueInterceptor{Store: db, payload: make(chan types.MessagePayload, 1), metadataFn: metadataFn} +} + +func (e *enqueueInterceptor) EnqueueNotificationMessage(_ context.Context, arg database.EnqueueNotificationMessageParams) (database.NotificationMessage, error) { + var payload types.MessagePayload + err := json.Unmarshal(arg.Payload, &payload) + if err != nil { + return database.NotificationMessage{}, err + } + + e.payload <- payload + return database.NotificationMessage{}, err +} + +func (e *enqueueInterceptor) FetchNewMessageMetadata(_ context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) { + return e.metadataFn(), nil +} diff --git a/coderd/notifications/noop.go b/coderd/notifications/noop.go new file mode 100644 index 0000000000000..c8d3ed0a163da --- /dev/null +++ b/coderd/notifications/noop.go @@ -0,0 +1,21 @@ +package notifications + +import ( + "context" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/notifications/types" +) + +type NoopManager struct{} + +// NewNoopManager builds a NoopManager which is used to fulfill the contract for enqueuing notifications, if ExperimentNotifications is not set. +func NewNoopManager() *NoopManager { + return &NoopManager{} +} + +func (*NoopManager) Enqueue(context.Context, uuid.UUID, uuid.UUID, types.Labels, string, ...uuid.UUID) (*uuid.UUID, error) { + // nolint:nilnil // irrelevant. + return nil, nil +} diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go new file mode 100644 index 0000000000000..8ba00df44cc01 --- /dev/null +++ b/coderd/notifications/notifications_test.go @@ -0,0 +1,521 @@ +package notifications_test + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + smtpmock "github.com/mocktools/go-smtp-mock/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.uber.org/goleak" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/pubsub" + "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/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +func TestMain(m *testing.M) { + goleak.VerifyTestMain(m) +} + +// TestBasicNotificationRoundtrip enqueues a message to the store, waits for it to be acquired by a notifier, +// and passes it off to a fake handler. +// TODO: split this test up into table tests or separate tests. +func TestBasicNotificationRoundtrip(t *testing.T) { + t.Parallel() + + // setup + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + ctx, logger, db, ps := setup(t) + + // given + handler := &fakeHandler{} + fakeHandlers, err := notifications.NewHandlerRegistry(handler) + require.NoError(t, err) + + cfg := defaultNotificationsConfig() + manager, err := notifications.NewManager(cfg, db, logger, defaultHelpers()) + require.NoError(t, err) + manager.WithHandlers(fakeHandlers) + notifications.RegisterInstance(manager) + t.Cleanup(func() { + require.NoError(t, manager.Stop(ctx)) + }) + + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + user := coderdtest.CreateFirstUser(t, client) + + // when + sid, err := manager.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"type": "success"}, "test") + require.NoError(t, err) + fid, err := manager.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"type": "failure"}, "test") + require.NoError(t, err) + + manager.Run(ctx, 1) + + // then + require.Eventually(t, func() bool { return handler.succeeded == sid.String() }, testutil.WaitLong, testutil.IntervalMedium) + require.Eventually(t, func() bool { return handler.failed == fid.String() }, testutil.WaitLong, testutil.IntervalMedium) +} + +func TestSMTPDispatch(t *testing.T) { + t.Parallel() + + // setup + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + ctx, logger, db, ps := setup(t) + + // start mock SMTP server + mockSMTPSrv := smtpmock.New(smtpmock.ConfigurationAttr{ + LogToStdout: true, + LogServerActivity: true, + }) + require.NoError(t, mockSMTPSrv.Start()) + t.Cleanup(func() { + require.NoError(t, mockSMTPSrv.Stop()) + }) + + // given + const from = "danny@coder.com" + cfg := defaultNotificationsConfig() + cfg.SMTP = codersdk.NotificationsEmailConfig{ + From: from, + Smarthost: serpent.HostPort{Host: "localhost", Port: fmt.Sprintf("%d", mockSMTPSrv.PortNumber())}, + Hello: "localhost", + } + handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, logger)) + fakeHandlers, err := notifications.NewHandlerRegistry(handler) + require.NoError(t, err) + + manager, err := notifications.NewManager(cfg, db, logger, defaultHelpers()) + require.NoError(t, err) + manager.WithHandlers(fakeHandlers) + + notifications.RegisterInstance(manager) + t.Cleanup(func() { + require.NoError(t, manager.Stop(ctx)) + }) + + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + first := coderdtest.CreateFirstUser(t, client) + _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Email = "bob@coder.com" + r.Username = "bob" + }) + + // when + msgID, err := manager.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{}, "test") + require.NoError(t, err) + + manager.Run(ctx, 1) + + // then + require.Eventually(t, func() bool { + assert.Nil(t, handler.lastErr.Load()) + assert.True(t, handler.retryable.Load() == 0) + return handler.sent.Load() == 1 + }, testutil.WaitLong, testutil.IntervalMedium) + + msgs := mockSMTPSrv.MessagesAndPurge() + require.Len(t, msgs, 1) + require.Contains(t, msgs[0].MsgRequest(), fmt.Sprintf("From: %s", from)) + require.Contains(t, msgs[0].MsgRequest(), fmt.Sprintf("To: %s", user.Email)) + require.Contains(t, msgs[0].MsgRequest(), fmt.Sprintf("Message-Id: %s", msgID)) +} + +func TestWebhookDispatch(t *testing.T) { + t.Parallel() + + // setup + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + ctx, logger, db, ps := setup(t) + + var ( + msgID *uuid.UUID + input types.Labels + ) + + sent := make(chan bool, 1) + // Mock server to simulate webhook endpoint. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload dispatch.WebhookPayload + err := json.NewDecoder(r.Body).Decode(&payload) + require.NoError(t, err) + + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + require.EqualValues(t, "1.0", payload.Version) + require.Equal(t, *msgID, payload.MsgID) + require.Equal(t, payload.Payload.Labels, input) + require.Equal(t, payload.Payload.UserEmail, "bob@coder.com") + require.Equal(t, payload.Payload.UserName, "bob") + require.Equal(t, payload.Payload.NotificationName, "Workspace Deleted") + + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte("noted.")) + require.NoError(t, err) + sent <- true + })) + defer server.Close() + + endpoint, err := url.Parse(server.URL) + require.NoError(t, err) + + // given + cfg := defaultNotificationsConfig() + cfg.Method = serpent.String(database.NotificationMethodWebhook) + cfg.Webhook = codersdk.NotificationsWebhookConfig{ + Endpoint: *serpent.URLOf(endpoint), + } + manager, err := notifications.NewManager(cfg, db, logger, defaultHelpers()) + require.NoError(t, err) + notifications.RegisterInstance(manager) + t.Cleanup(func() { + require.NoError(t, manager.Stop(ctx)) + }) + + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + first := coderdtest.CreateFirstUser(t, client) + _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Email = "bob@coder.com" + r.Username = "bob" + }) + + // when + input = types.Labels{ + "a": "b", + "c": "d", + } + msgID, err = manager.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, input, "test") + require.NoError(t, err) + + manager.Run(ctx, 1) + + // then + require.Eventually(t, func() bool { return <-sent }, testutil.WaitShort, testutil.IntervalFast) +} + +// TestBackpressure validates that delays in processing the buffered updates will result in slowed dequeue rates. +// As a side-effect, this also tests the graceful shutdown and flushing of the buffers. +func TestBackpressure(t *testing.T) { + t.Parallel() + + // setup + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + + ctx, logger, db, ps := setup(t) + + // Mock server to simulate webhook endpoint. + var received atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload dispatch.WebhookPayload + err := json.NewDecoder(r.Body).Decode(&payload) + require.NoError(t, err) + + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte("noted.")) + require.NoError(t, err) + + received.Add(1) + })) + defer server.Close() + + endpoint, err := url.Parse(server.URL) + require.NoError(t, err) + + cfg := defaultNotificationsConfig() + cfg.Method = serpent.String(database.NotificationMethodWebhook) + cfg.Webhook = codersdk.NotificationsWebhookConfig{ + Endpoint: *serpent.URLOf(endpoint), + } + + // Tune the queue to fetch often. + const fetchInterval = time.Millisecond * 200 + const batchSize = 10 + cfg.FetchInterval = serpent.Duration(fetchInterval) + cfg.LeaseCount = serpent.Int64(batchSize) + + // Shrink buffers down and increase flush interval to provoke backpressure. + // Flush buffers every 5 fetch intervals. + const syncInterval = time.Second + cfg.StoreSyncInterval = serpent.Duration(syncInterval) + cfg.StoreSyncBufferSize = serpent.Int64(2) + + handler := newDispatchInterceptor(dispatch.NewWebhookHandler(cfg.Webhook, logger)) + fakeHandlers, err := notifications.NewHandlerRegistry(handler) + require.NoError(t, err) + + // Intercept calls to submit the buffered updates to the store. + storeInterceptor := &bulkUpdateInterceptor{Store: db} + + // given + manager, err := notifications.NewManager(cfg, storeInterceptor, logger, defaultHelpers()) + require.NoError(t, err) + manager.WithHandlers(fakeHandlers) + + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + first := coderdtest.CreateFirstUser(t, client) + _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Email = "bob@coder.com" + r.Username = "bob" + }) + + // when + const totalMessages = 30 + for i := 0; i < totalMessages; i++ { + _, err = manager.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{"i": fmt.Sprintf("%d", i)}, "test") + require.NoError(t, err) + } + + // Start two notifiers. + const notifiers = 2 + manager.Run(ctx, notifiers) + + // then + + // Wait for 3 fetch intervals, then check progress. + time.Sleep(fetchInterval * 3) + + // We expect the notifiers will have dispatched ONLY the initial batch of messages. + // In other words, the notifiers should have dispatched 3 batches by now, but because the buffered updates have not + // been processed there is backpressure. + require.EqualValues(t, notifiers*batchSize, handler.sent.Load()+handler.err.Load()) + // We expect that the store will have received NO updates. + require.EqualValues(t, 0, storeInterceptor.sent.Load()+storeInterceptor.failed.Load()) + + // However, when we Stop() the manager the backpressure will be relieved and the buffered updates will ALL be flushed, + // since all the goroutines blocked on writing updates to the buffer will be unblocked and will complete. + require.NoError(t, manager.Stop(ctx)) + require.EqualValues(t, notifiers*batchSize, storeInterceptor.sent.Load()+storeInterceptor.failed.Load()) +} + +func TestRetries(t *testing.T) { + t.Parallel() + + // setup + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + + ctx, logger, db, ps := setup(t) + + const maxAttempts = 3 + + // Mock server to simulate webhook endpoint. + receivedMap := make(map[uuid.UUID]*atomic.Int32) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var payload dispatch.WebhookPayload + err := json.NewDecoder(r.Body).Decode(&payload) + require.NoError(t, err) + + if _, ok := receivedMap[payload.MsgID]; !ok { + receivedMap[payload.MsgID] = &atomic.Int32{} + } + + counter := receivedMap[payload.MsgID] + + // Let the request succeed if this is its last attempt. + if counter.Add(1) == maxAttempts { + w.WriteHeader(http.StatusOK) + _, err = w.Write([]byte("noted.")) + require.NoError(t, err) + return + } + + w.WriteHeader(http.StatusInternalServerError) + _, err = w.Write([]byte("retry again later...")) + require.NoError(t, err) + })) + defer server.Close() + + endpoint, err := url.Parse(server.URL) + require.NoError(t, err) + + cfg := defaultNotificationsConfig() + cfg.Method = serpent.String(database.NotificationMethodWebhook) + cfg.Webhook = codersdk.NotificationsWebhookConfig{ + Endpoint: *serpent.URLOf(endpoint), + } + + cfg.MaxSendAttempts = maxAttempts + + // Tune intervals low to speed up test. + cfg.StoreSyncInterval = serpent.Duration(time.Millisecond * 100) + cfg.RetryInterval = serpent.Duration(time.Second) // query uses second-precision + cfg.FetchInterval = serpent.Duration(time.Millisecond * 100) + + handler := newDispatchInterceptor(dispatch.NewWebhookHandler(cfg.Webhook, logger)) + fakeHandlers, err := notifications.NewHandlerRegistry(handler) + require.NoError(t, err) + + // Intercept calls to submit the buffered updates to the store. + storeInterceptor := &bulkUpdateInterceptor{Store: db} + + // given + manager, err := notifications.NewManager(cfg, storeInterceptor, logger, defaultHelpers()) + require.NoError(t, err) + manager.WithHandlers(fakeHandlers) + + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + first := coderdtest.CreateFirstUser(t, client) + _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { + r.Email = "bob@coder.com" + r.Username = "bob" + }) + + // when + for i := 0; i < 1; i++ { + _, err = manager.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{"i": fmt.Sprintf("%d", i)}, "test") + require.NoError(t, err) + } + + // Start two notifiers. + const notifiers = 2 + manager.Run(ctx, notifiers) + + // then + require.Eventually(t, func() bool { + return storeInterceptor.failed.Load() == maxAttempts-1 && + storeInterceptor.sent.Load() == 1 + }, testutil.WaitLong, testutil.IntervalFast) +} + +func setup(t *testing.T) (context.Context, slog.Logger, database.Store, *pubsub.PGPubsub) { + t.Helper() + + connectionURL, closeFunc, err := dbtestutil.Open() + require.NoError(t, err) + t.Cleanup(closeFunc) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) + t.Cleanup(cancel) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + + db := database.New(sqlDB) + ps, err := pubsub.New(ctx, logger, sqlDB, connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, ps.Close()) + }) + + // nolint:gocritic // unit tests. + return dbauthz.AsSystemRestricted(ctx), logger, db, ps +} + +type fakeHandler struct { + succeeded string + failed string +} + +func (*fakeHandler) NotificationMethod() database.NotificationMethod { + return database.NotificationMethodSmtp +} + +func (f *fakeHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) { + return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { + if payload.Labels.Get("type") == "success" { + f.succeeded = msgID.String() + } else { + f.failed = msgID.String() + } + return false, nil + }, nil +} + +type dispatchInterceptor struct { + handler notifications.Handler + + sent atomic.Int32 + retryable atomic.Int32 + unretryable atomic.Int32 + err atomic.Int32 + lastErr atomic.Value +} + +func newDispatchInterceptor(h notifications.Handler) *dispatchInterceptor { + return &dispatchInterceptor{handler: h} +} + +func (i *dispatchInterceptor) NotificationMethod() database.NotificationMethod { + return i.handler.NotificationMethod() +} + +func (i *dispatchInterceptor) Dispatcher(payload types.MessagePayload, title, body string) (dispatch.DeliveryFunc, error) { + return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { + deliveryFn, err := i.handler.Dispatcher(payload, title, body) + if err != nil { + return false, err + } + + retryable, err = deliveryFn(ctx, msgID) + + if err != nil { + i.err.Add(1) + i.lastErr.Store(err) + } + + switch { + case !retryable && err == nil: + i.sent.Add(1) + case retryable: + i.retryable.Add(1) + case !retryable && err != nil: + i.unretryable.Add(1) + } + return retryable, err + }, nil +} + +func defaultNotificationsConfig() codersdk.NotificationsConfig { + return codersdk.NotificationsConfig{ + Method: serpent.String(database.NotificationMethodSmtp), + MaxSendAttempts: 5, + RetryInterval: serpent.Duration(time.Minute * 5), + StoreSyncInterval: serpent.Duration(time.Second * 2), + StoreSyncBufferSize: 50, + LeasePeriod: serpent.Duration(time.Minute * 2), + LeaseCount: 10, + FetchInterval: serpent.Duration(time.Second * 10), + DispatchTimeout: serpent.Duration(time.Minute), + SMTP: codersdk.NotificationsEmailConfig{}, + Webhook: codersdk.NotificationsWebhookConfig{}, + } +} + +func defaultHelpers() map[string]any { + return map[string]any{ + "base_url": func() string { return "http://test.com" }, + } +} diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go new file mode 100644 index 0000000000000..7a71fe5c8bcdb --- /dev/null +++ b/coderd/notifications/notifier.go @@ -0,0 +1,236 @@ +package notifications + +import ( + "context" + "encoding/json" + "sync" + "time" + + "golang.org/x/sync/errgroup" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/notifications/dispatch" + "github.com/coder/coder/v2/coderd/notifications/render" + "github.com/coder/coder/v2/coderd/notifications/types" + "github.com/coder/coder/v2/codersdk" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" +) + +// notifier is a consumer of the notifications_messages queue. It dequeues messages from that table and processes them +// through a pipeline of fetch -> prepare -> render -> acquire handler -> deliver. +type notifier struct { + id int + cfg codersdk.NotificationsConfig + ctx context.Context + log slog.Logger + store Store + + tick *time.Ticker + stopOnce sync.Once + quit chan any + done chan any + + handlers *HandlerRegistry +} + +func newNotifier(ctx context.Context, cfg codersdk.NotificationsConfig, id int, log slog.Logger, db Store, hr *HandlerRegistry) *notifier { + return ¬ifier{ + id: id, + ctx: ctx, + cfg: cfg, + log: log.Named("notifier").With(slog.F("id", id)), + quit: make(chan any), + done: make(chan any), + tick: time.NewTicker(cfg.FetchInterval.Value()), + store: db, + handlers: hr, + } +} + +// run is the main loop of the notifier. +func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failure chan<- dispatchResult) error { + defer func() { + close(n.done) + n.log.Info(context.Background(), "gracefully stopped") + }() + + // TODO: idea from Cian: instead of querying the database on a short interval, we could wait for pubsub notifications. + // if 100 notifications are enqueued, we shouldn't activate this routine for each one; so how to debounce these? + // PLUS we should also have an interval (but a longer one, maybe 1m) to account for retries (those will not get + // triggered by a code path, but rather by a timeout expiring which makes the message retryable) + for { + select { + case <-ctx.Done(): + return xerrors.Errorf("notifier %d context canceled: %w", n.id, ctx.Err()) + case <-n.quit: + return nil + default: + } + + // 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. + select { + case <-ctx.Done(): + return xerrors.Errorf("notifier %d context canceled: %w", n.id, ctx.Err()) + case <-n.quit: + return nil + case <-n.tick.C: + // sleep until next invocation + } + } +} + +// 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. +// +// NOTE: it is _possible_ that these goroutines could block for long enough to exceed CODER_NOTIFICATIONS_DISPATCH_TIMEOUT, +// resulting in a failed attempt for each notification when their contexts are canceled; this is not possible with the +// default configurations but could be brought about by an operator tuning things incorrectly. +func (n *notifier) process(ctx context.Context, success chan<- dispatchResult, failure chan<- dispatchResult) error { + n.log.Debug(ctx, "attempting to dequeue messages") + + msgs, err := n.fetch(ctx) + if err != nil { + return xerrors.Errorf("fetch messages: %w", err) + } + + n.log.Debug(ctx, "dequeued messages", slog.F("count", len(msgs))) + if len(msgs) == 0 { + return nil + } + + var eg errgroup.Group + for _, msg := range msgs { + // A message failing to be prepared correctly should not affect other messages. + deliverFn, err := n.prepare(ctx, msg) + if err != nil { + n.log.Warn(ctx, "dispatcher construction failed", slog.F("msg_id", msg.ID), slog.Error(err)) + failure <- newFailedDispatch(n.id, msg.ID, err, false) + continue + } + + eg.Go(func() error { + // Dispatch must only return an error for exceptional cases, NOT for failed messages. + return n.deliver(ctx, msg, deliverFn, success, failure) + }) + } + + if err = eg.Wait(); err != nil { + n.log.Debug(ctx, "dispatch failed", slog.Error(err)) + return xerrors.Errorf("dispatch failed: %w", err) + } + + n.log.Debug(ctx, "dispatch completed", slog.F("count", len(msgs))) + return nil +} + +// fetch retrieves messages from the queue by "acquiring a lease" whereby this notifier is the exclusive handler of these +// messages until they are dispatched - or until the lease expires (in exceptional cases). +func (n *notifier) fetch(ctx context.Context) ([]database.AcquireNotificationMessagesRow, error) { + msgs, err := n.store.AcquireNotificationMessages(ctx, database.AcquireNotificationMessagesParams{ + Count: int32(n.cfg.LeaseCount), + MaxAttemptCount: int32(n.cfg.MaxSendAttempts), + NotifierID: int32(n.id), + LeaseSeconds: int32(n.cfg.LeasePeriod.Value().Seconds()), + }) + if err != nil { + return nil, xerrors.Errorf("acquire messages: %w", err) + } + + return msgs, nil +} + +// prepare has two roles: +// 1. render the title & body templates +// 2. build a dispatcher from the given message, payload, and these templates - to be used for delivering the notification +func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotificationMessagesRow) (dispatch.DeliveryFunc, error) { + select { + case <-ctx.Done(): + return nil, ctx.Err() + default: + } + + // NOTE: when we change the format of the MessagePayload, we have to bump its version and handle unmarshalling + // differently here based on that version. + var payload types.MessagePayload + err := json.Unmarshal(msg.Payload, &payload) + if err != nil { + return nil, xerrors.Errorf("unmarshal payload: %w", err) + } + + handler, err := n.handlers.Resolve(msg.Method) + if err != nil { + return nil, xerrors.Errorf("resolve handler: %w", err) + } + + var ( + title, body string + ) + if title, err = render.GoTemplate(msg.TitleTemplate, payload, nil); err != nil { + return nil, xerrors.Errorf("render title: %w", err) + } + if body, err = render.GoTemplate(msg.BodyTemplate, payload, nil); err != nil { + return nil, xerrors.Errorf("render body: %w", err) + } + + return handler.Dispatcher(payload, title, body) +} + +// deliver sends a given notification message via its defined method. +// This method *only* returns an error when a context error occurs; any other error is interpreted as a failure to +// deliver the notification and as such the message will be marked as failed (to later be optionally retried). +func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotificationMessagesRow, deliver dispatch.DeliveryFunc, success, failure chan<- dispatchResult) error { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + ctx, cancel := context.WithTimeout(ctx, n.cfg.DispatchTimeout.Value()) + defer cancel() + logger := n.log.With(slog.F("msg_id", msg.ID), slog.F("method", msg.Method)) + + retryable, err := deliver(ctx, msg.ID) + if err != nil { + // Don't try to accumulate message responses if the context has been canceled. + // + // This message's lease will expire in the store and will be requeued. + // It's possible this will lead to a message being delivered more than once, and that is why Stop() is preferable + // instead of canceling the context. + // + // In the case of backpressure (i.e. the success/failure channels are full because the database is slow), + // and this caused delivery timeout (CODER_NOTIFICATIONS_DISPATCH_TIMEOUT), we can't append any more updates to + // the channels otherwise this, too, will block. + if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) { + return err + } + + logger.Warn(ctx, "message dispatch failed", slog.Error(err)) + failure <- newFailedDispatch(n.id, msg.ID, err, retryable) + } else { + logger.Debug(ctx, "message dispatch succeeded") + success <- newSuccessfulDispatch(n.id, msg.ID) + } + + return nil +} + +// stop stops the notifier from processing any new notifications. +// This is a graceful stop, so any in-flight notifications will be completed before the notifier stops. +// Once a notifier has stopped, it cannot be restarted. +func (n *notifier) stop() { + n.stopOnce.Do(func() { + n.log.Info(context.Background(), "graceful stop requested") + + n.tick.Stop() + close(n.quit) + <-n.done + }) +} diff --git a/coderd/notifications/provider.go b/coderd/notifications/provider.go new file mode 100644 index 0000000000000..3f8fc7324406e --- /dev/null +++ b/coderd/notifications/provider.go @@ -0,0 +1,44 @@ +package notifications + +import ( + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/database" +) + +type HandlerRegistry struct { + handlers map[database.NotificationMethod]Handler +} + +func NewHandlerRegistry(handlers ...Handler) (*HandlerRegistry, error) { + reg := &HandlerRegistry{ + handlers: make(map[database.NotificationMethod]Handler), + } + + for _, h := range handlers { + if err := reg.Register(h); err != nil { + return nil, err + } + } + + return reg, nil +} + +func (p *HandlerRegistry) Register(handler Handler) error { + method := handler.NotificationMethod() + if _, found := p.handlers[method]; found { + return xerrors.Errorf("%q already registered", method) + } + + p.handlers[method] = handler + return nil +} + +func (p *HandlerRegistry) Resolve(method database.NotificationMethod) (Handler, error) { + out, found := p.handlers[method] + if !found { + return nil, xerrors.Errorf("could not resolve handler by method %q", method) + } + + return out, nil +} diff --git a/coderd/notifications/render/gotmpl.go b/coderd/notifications/render/gotmpl.go new file mode 100644 index 0000000000000..e194c9837d2a9 --- /dev/null +++ b/coderd/notifications/render/gotmpl.go @@ -0,0 +1,26 @@ +package render + +import ( + "strings" + "text/template" + + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/notifications/types" +) + +// GoTemplate attempts to substitute the given payload into the given template using Go's templating syntax. +// TODO: memoize templates for memory efficiency? +func GoTemplate(in string, payload types.MessagePayload, extraFuncs template.FuncMap) (string, error) { + tmpl, err := template.New("text").Funcs(extraFuncs).Parse(in) + if err != nil { + return "", xerrors.Errorf("template parse: %w", err) + } + + var out strings.Builder + if err = tmpl.Execute(&out, payload); err != nil { + return "", xerrors.Errorf("template execute: %w", err) + } + + return out.String(), nil +} diff --git a/coderd/notifications/render/gotmpl_test.go b/coderd/notifications/render/gotmpl_test.go new file mode 100644 index 0000000000000..32970dd6cd8b6 --- /dev/null +++ b/coderd/notifications/render/gotmpl_test.go @@ -0,0 +1,59 @@ +package render_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/notifications/render" + + "github.com/coder/coder/v2/coderd/notifications/types" +) + +func TestGoTemplate(t *testing.T) { + t.Parallel() + + const userEmail = "bob@xyz.com" + + tests := []struct { + name string + in string + payload types.MessagePayload + expectedOutput string + expectedErr error + }{ + { + name: "top-level variables are accessible and substituted", + in: "{{ .UserEmail }}", + payload: types.MessagePayload{UserEmail: userEmail}, + expectedOutput: userEmail, + expectedErr: nil, + }, + { + name: "input labels are accessible and substituted", + in: "{{ .Labels.user_email }}", + payload: types.MessagePayload{Labels: map[string]string{ + "user_email": userEmail, + }}, + expectedOutput: userEmail, + expectedErr: nil, + }, + } + + for _, tc := range tests { + tc := tc // unnecessary as of go1.22 but the linter is outdated + + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + out, err := render.GoTemplate(tc.in, tc.payload, nil) + if tc.expectedErr == nil { + require.NoError(t, err) + } else { + require.ErrorIs(t, err, tc.expectedErr) + } + + require.Equal(t, tc.expectedOutput, out) + }) + } +} diff --git a/coderd/notifications/render/markdown.go b/coderd/notifications/render/markdown.go new file mode 100644 index 0000000000000..1e7a597edf3cc --- /dev/null +++ b/coderd/notifications/render/markdown.go @@ -0,0 +1,113 @@ +package render + +import ( + "bytes" + "strings" + + "github.com/charmbracelet/glamour" + "github.com/charmbracelet/glamour/ansi" + gomarkdown "github.com/gomarkdown/markdown" + "github.com/gomarkdown/markdown/html" + "github.com/gomarkdown/markdown/parser" + "golang.org/x/xerrors" +) + +// TODO: this is (mostly) a copy of coderd/parameter/renderer.go; unify them? + +var plaintextStyle = ansi.StyleConfig{ + Document: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + BlockQuote: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + Paragraph: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + List: ansi.StyleList{ + StyleBlock: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + LevelIndent: 4, + }, + Heading: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + H1: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + H2: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + H3: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + H4: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + H5: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + H6: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + Strikethrough: ansi.StylePrimitive{}, + Emph: ansi.StylePrimitive{}, + Strong: ansi.StylePrimitive{}, + HorizontalRule: ansi.StylePrimitive{}, + Item: ansi.StylePrimitive{}, + Enumeration: ansi.StylePrimitive{ + BlockPrefix: ". ", + }, Task: ansi.StyleTask{}, + Link: ansi.StylePrimitive{ + Format: "({{.text}})", + }, + LinkText: ansi.StylePrimitive{ + Format: "{{.text}}", + }, + ImageText: ansi.StylePrimitive{ + Format: "{{.text}}", + }, + Image: ansi.StylePrimitive{ + Format: "({{.text}})", + }, + Code: ansi.StyleBlock{ + StylePrimitive: ansi.StylePrimitive{}, + }, + CodeBlock: ansi.StyleCodeBlock{ + StyleBlock: ansi.StyleBlock{}, + }, + Table: ansi.StyleTable{}, + DefinitionDescription: ansi.StylePrimitive{}, +} + +// Plaintext function converts the description with optional Markdown tags +// to the plaintext form. +func Plaintext(markdown string) (string, error) { + renderer, err := glamour.NewTermRenderer( + glamour.WithStandardStyle("ascii"), + glamour.WithWordWrap(0), // don't need to add spaces in the end of line + glamour.WithStyles(plaintextStyle), + ) + if err != nil { + return "", xerrors.Errorf("can't initialize the Markdown renderer: %w", err) + } + + output, err := renderer.Render(markdown) + if err != nil { + return "", xerrors.Errorf("can't render description to plaintext: %w", err) + } + defer renderer.Close() + + return strings.TrimSpace(output), nil +} + +func HTML(markdown string) (string, error) { + p := parser.NewWithExtensions(parser.CommonExtensions | parser.HardLineBreak) // Added HardLineBreak. + doc := p.Parse([]byte(markdown)) + renderer := html.NewRenderer(html.RendererOptions{ + Flags: html.CommonFlags | html.SkipHTML, + }, + ) + return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer))), nil +} diff --git a/coderd/notifications/singleton.go b/coderd/notifications/singleton.go new file mode 100644 index 0000000000000..1d584c5227329 --- /dev/null +++ b/coderd/notifications/singleton.go @@ -0,0 +1,38 @@ +package notifications + +import ( + "context" + "sync" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/notifications/types" +) + +var ( + singleton Enqueuer + singletonMu sync.Mutex + + SingletonNotRegisteredErr = xerrors.New("singleton not registered") +) + +// RegisterInstance receives a Manager reference to act as a Singleton. +// We use a Singleton to centralize the logic around enqueueing notifications, instead of requiring that an instance +// of the Manager be passed around the codebase. +func RegisterInstance(m Enqueuer) { + singletonMu.Lock() + defer singletonMu.Unlock() + + singleton = m +} + +// Enqueue queues a notification message for later delivery. +// This is a delegator for the underlying notifications singleton. +func Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels types.Labels, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { + if singleton == nil { + return nil, SingletonNotRegisteredErr + } + + return singleton.Enqueue(ctx, userID, templateID, labels, createdBy, targets...) +} diff --git a/coderd/notifications/spec.go b/coderd/notifications/spec.go new file mode 100644 index 0000000000000..3cbc19075ff0d --- /dev/null +++ b/coderd/notifications/spec.go @@ -0,0 +1,36 @@ +package notifications + +import ( + "context" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/notifications/dispatch" + "github.com/coder/coder/v2/coderd/notifications/types" +) + +// Store defines the API between the notifications system and the storage. +// This abstraction is in place so that we can intercept the direct database interactions, or (later) swap out these calls +// with dRPC calls should we want to split the notifiers out into their own component for high availability/throughput. +// TODO: don't use database types here +type Store interface { + AcquireNotificationMessages(ctx context.Context, params database.AcquireNotificationMessagesParams) ([]database.AcquireNotificationMessagesRow, error) + BulkMarkNotificationMessagesSent(ctx context.Context, arg database.BulkMarkNotificationMessagesSentParams) (int64, error) + BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) + EnqueueNotificationMessage(ctx context.Context, arg database.EnqueueNotificationMessageParams) (database.NotificationMessage, error) + FetchNewMessageMetadata(ctx context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) +} + +// Handler is responsible for preparing and delivering a notification by a given method. +type Handler interface { + NotificationMethod() database.NotificationMethod + + // Dispatcher constructs a DeliveryFunc to be used for delivering a notification via the chosen method. + Dispatcher(payload types.MessagePayload, title, body string) (dispatch.DeliveryFunc, error) +} + +// Enqueuer enqueues a new notification message in the store and returns its ID, should it enqueue without failure. +type Enqueuer interface { + Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels types.Labels, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) +} diff --git a/coderd/notifications/system/system.go b/coderd/notifications/system/system.go new file mode 100644 index 0000000000000..8d3be1bec68bb --- /dev/null +++ b/coderd/notifications/system/system.go @@ -0,0 +1,19 @@ +package system + +import ( + "context" + + "github.com/google/uuid" + + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/types" +) + +// NotifyWorkspaceDeleted notifies the given user that their workspace was deleted. +func NotifyWorkspaceDeleted(ctx context.Context, userID uuid.UUID, name, reason, createdBy string, targets ...uuid.UUID) { + _, _ = notifications.Enqueue(ctx, userID, notifications.TemplateWorkspaceDeleted, + types.Labels{ + "name": name, + "reason": reason, + }, createdBy, targets...) +} diff --git a/coderd/notifications/system/system_test.go b/coderd/notifications/system/system_test.go new file mode 100644 index 0000000000000..5ddda14374e76 --- /dev/null +++ b/coderd/notifications/system/system_test.go @@ -0,0 +1,46 @@ +package system_test + +import ( + "context" + "testing" + "time" + + "github.com/coder/coder/v2/coderd/notifications" + "github.com/coder/coder/v2/coderd/notifications/system" + "github.com/coder/coder/v2/coderd/notifications/types" + "github.com/coder/coder/v2/testutil" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// TestNotifyWorkspaceDeleted tests the "public" interface for enqueueing notifications. +// Calling system.NotifyWorkspaceDeleted uses the Enqueuer singleton to enqueue the notification. +func TestNotifyWorkspaceDeleted(t *testing.T) { + // given + manager := newFakeEnqueuer() + notifications.RegisterInstance(manager) + + // when + system.NotifyWorkspaceDeleted(context.Background(), uuid.New(), "test", "reason", "test") + + // then + select { + case ok := <-manager.enqueued: + require.True(t, ok) + case <-time.After(testutil.WaitShort): + t.Fatalf("timed out") + } +} + +type fakeEnqueuer struct { + enqueued chan bool +} + +func newFakeEnqueuer() *fakeEnqueuer { + return &fakeEnqueuer{enqueued: make(chan bool, 1)} +} + +func (f *fakeEnqueuer) Enqueue(context.Context, uuid.UUID, uuid.UUID, types.Labels, string, ...uuid.UUID) (*uuid.UUID, error) { + f.enqueued <- true + return nil, nil +} diff --git a/coderd/notifications/types/cta.go b/coderd/notifications/types/cta.go new file mode 100644 index 0000000000000..d47ead0259251 --- /dev/null +++ b/coderd/notifications/types/cta.go @@ -0,0 +1,6 @@ +package types + +type TemplateAction struct { + Label string `json:"label"` + URL string `json:"url"` +} diff --git a/coderd/notifications/types/labels.go b/coderd/notifications/types/labels.go new file mode 100644 index 0000000000000..9126c5409a742 --- /dev/null +++ b/coderd/notifications/types/labels.go @@ -0,0 +1,72 @@ +package types + +import ( + "fmt" +) + +// Labels represents the metadata defined in a notification message, which will be used to augment the notification +// display and delivery. +type Labels map[string]string + +func (l Labels) GetStrict(k string) (string, bool) { + v, ok := l[k] + return v, ok +} + +func (l Labels) Get(k string) string { + return l[k] +} + +func (l Labels) Set(k, v string) { + l[k] = v +} + +func (l Labels) SetValue(k string, v fmt.Stringer) { + l[k] = v.String() +} + +// Merge combines two Labels. Keys declared on the given Labels will win over the existing Labels. +func (l Labels) Merge(m Labels) { + if len(m) == 0 { + return + } + + for k, v := range m { + l[k] = v + } +} + +func (l Labels) Delete(k string) { + delete(l, k) +} + +func (l Labels) Contains(ks ...string) bool { + for _, k := range ks { + if _, has := l[k]; !has { + return false + } + } + + return true +} + +func (l Labels) Missing(ks ...string) (out []string) { + for _, k := range ks { + if _, has := l[k]; !has { + out = append(out, k) + } + } + + return out +} + +// Cut returns the given key from the labels, deleting it from labels. +func (l Labels) Cut(k string) string { + v, ok := l.GetStrict(k) + if !ok { + return "" + } + + l.Delete(k) + return v +} diff --git a/coderd/notifications/types/payload.go b/coderd/notifications/types/payload.go new file mode 100644 index 0000000000000..a33512b602a3a --- /dev/null +++ b/coderd/notifications/types/payload.go @@ -0,0 +1,19 @@ +package types + +// MessagePayload describes the JSON payload to be stored alongside the notification message, which specifies all of its +// metadata, labels, and routing information. +// +// Any BC-incompatible changes must bump the version, and special handling must be put in place to unmarshal multiple versions. +type MessagePayload struct { + Version string `json:"_version"` + + NotificationName string `json:"notification_name"` + CreatedBy string `json:"created_by"` + + UserID string `json:"user_id"` + UserEmail string `json:"user_email"` + UserName string `json:"user_name"` + + Actions []TemplateAction `json:"actions"` + Labels Labels `json:"labels"` +} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 413ed999aa6a6..db919979b2efb 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -32,6 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" + "github.com/coder/coder/v2/coderd/notifications/system" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" @@ -1394,6 +1395,7 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) // This is for deleting a workspace! return nil } + s.notifyWorkspaceDeleted(ctx, workspace, workspaceBuild) err = db.UpdateWorkspaceDeletedByID(ctx, database.UpdateWorkspaceDeletedByIDParams{ ID: workspaceBuild.WorkspaceID, @@ -1511,6 +1513,24 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) return &proto.Empty{}, nil } +func (*server) notifyWorkspaceDeleted(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) { + var reason string + if build.Reason.Valid() { + switch build.Reason { + case database.BuildReasonInitiator: + reason = "initiated by user" + case database.BuildReasonAutodelete: + reason = "autodeleted due to dormancy" + default: + reason = string(build.Reason) + } + } + + system.NotifyWorkspaceDeleted(ctx, workspace.OwnerID, workspace.Name, reason, "provisionerdserver", + // Associate this notification with all the related entities. + workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID) +} + func (s *server) startTrace(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { return s.Tracer.Start(ctx, name, append(opts, trace.WithAttributes( semconv.ServiceNameKey.String("coderd.provisionerd"), diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 7b13d083a4435..47950218ff76f 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -204,6 +204,7 @@ type DeploymentValues struct { Healthcheck HealthcheckConfig `json:"healthcheck,omitempty" typescript:",notnull"` CLIUpgradeMessage serpent.String `json:"cli_upgrade_message,omitempty" typescript:",notnull"` TermsOfServiceURL serpent.String `json:"terms_of_service_url,omitempty" typescript:",notnull"` + Notifications NotificationsConfig `json:"notifications,omitempty" typescript:",notnull"` Config serpent.YAMLConfigPath `json:"config,omitempty" typescript:",notnull"` WriteConfig serpent.Bool `json:"write_config,omitempty" typescript:",notnull"` @@ -455,6 +456,59 @@ type HealthcheckConfig struct { ThresholdDatabase serpent.Duration `json:"threshold_database" typescript:",notnull"` } +type NotificationsConfig struct { + // Retries. + MaxSendAttempts serpent.Int64 `json:"max_send_attempts" typescript:",notnull"` + RetryInterval serpent.Duration `json:"retry_interval" typescript:",notnull"` + + // Store updates. + StoreSyncInterval serpent.Duration `json:"sync_interval" typescript:",notnull"` + StoreSyncBufferSize serpent.Int64 `json:"sync_buffer_size" typescript:",notnull"` + + // Queue. + WorkerCount serpent.Int64 `json:"worker_count"` + LeasePeriod serpent.Duration `json:"lease_period"` + LeaseCount serpent.Int64 `json:"lease_count"` + FetchInterval serpent.Duration `json:"fetch_interval"` + + // Dispatch. + Method serpent.String `json:"method"` + DispatchTimeout serpent.Duration `json:"dispatch_timeout"` + SMTP NotificationsEmailConfig `json:"email" typescript:",notnull"` + Webhook NotificationsWebhookConfig `json:"webhook" typescript:",notnull"` +} + +type NotificationsEmailConfig struct { + // The sender's address. + From serpent.String `json:"from" typescript:",notnull"` + // The intermediary SMTP host through which emails are sent (host:port). + Smarthost serpent.HostPort `json:"smarthost" typescript:",notnull"` + // The hostname identifying the SMTP server. + Hello serpent.String `json:"hello" typescript:",notnull"` + + // TODO: Auth and Headers + //// Authentication details. + // Auth struct { + // // Username for CRAM-MD5/LOGIN/PLAIN auth; authentication is disabled if this is left blank. + // Username serpent.String `json:"username" typescript:",notnull"` + // // Password to use for LOGIN/PLAIN auth. + // Password serpent.String `json:"password" typescript:",notnull"` + // // File from which to load the password to use for LOGIN/PLAIN auth. + // PasswordFile serpent.String `json:"password_file" typescript:",notnull"` + // // Secret to use for CRAM-MD5 auth. + // Secret serpent.String `json:"secret" typescript:",notnull"` + // // Identity used for PLAIN auth. + // Identity serpent.String `json:"identity" typescript:",notnull"` + // } `json:"auth" typescript:",notnull"` + //// Additional headers to use in the SMTP request. + // Headers map[string]string `json:"headers" typescript:",notnull"` + // TODO: TLS +} + +type NotificationsWebhookConfig struct { + Endpoint serpent.URL `json:"endpoint" typescript:",notnull"` +} + const ( annotationFormatDuration = "format_duration" annotationEnterpriseKey = "enterprise" @@ -600,6 +654,20 @@ when required by your organization's security policy.`, Name: "Config", Description: `Use a YAML configuration file when your server launch become unwieldy.`, } + deploymentGroupNotifications = serpent.Group{ + Name: "Notifications", + YAML: "notifications", + } + deploymentGroupNotificationsEmail = serpent.Group{ + Name: "Email", + Parent: &deploymentGroupNotifications, + YAML: "email", + } + deploymentGroupNotificationsWebhook = serpent.Group{ + Name: "Webhook", + Parent: &deploymentGroupNotifications, + YAML: "webhook", + } ) httpAddress := serpent.Option{ @@ -2016,6 +2084,155 @@ Write out the current server config as YAML to stdout.`, YAML: "thresholdDatabase", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, + // Notifications Options + { + Name: "Notifications: Max Send Attempts", + Description: "The upper limit of attempts to send a notification.", + Flag: "notifications-max-send-attempts", + Env: "CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS", + Value: &c.Notifications.MaxSendAttempts, + Default: "5", + Group: &deploymentGroupNotifications, + YAML: "max-send-attempts", + }, + { + Name: "Notifications: Retry Interval", + Description: "The minimum time between retries.", + Flag: "notifications-retry-interval", + Env: "CODER_NOTIFICATIONS_RETRY_INTERVAL", + Value: &c.Notifications.RetryInterval, + Default: (time.Minute * 5).String(), + Group: &deploymentGroupNotifications, + YAML: "retry-interval", + }, + { + Name: "Notifications: Store Sync Interval", + Description: "The notifications system buffers message updates in memory to ease pressure on the database. " + + "This option controls how often it synchronizes its state with the database. The shorter this value the " + + "lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " + + "database. It is recommended to keep this option at its default value.", + Flag: "notifications-store-sync-interval", + Env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL", + Value: &c.Notifications.StoreSyncInterval, + Default: (time.Second * 2).String(), + Group: &deploymentGroupNotifications, + YAML: "store-sync-interval", + }, + { + Name: "Notifications: Store Sync Buffer Size", + Description: "The notifications system buffers message updates in memory to ease pressure on the database. " + + "This option controls how many updates are kept in memory. The lower this value the " + + "lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " + + "database. It is recommended to keep this option at its default value.", + Flag: "notifications-store-sync-buffer-size", + Env: "CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE", + Value: &c.Notifications.StoreSyncBufferSize, + Default: "50", + Group: &deploymentGroupNotifications, + YAML: "store-sync-buffer-size", + }, + { + Name: "Notifications: Worker Count", + Description: "How many workers should be processing messages in the queue; increase this count if notifications " + + "are not being processed fast enough.", + Flag: "notifications-worker-count", + Env: "CODER_NOTIFICATIONS_WORKER_COUNT", + Value: &c.Notifications.WorkerCount, + Default: "2", + Group: &deploymentGroupNotifications, + YAML: "worker-count", + }, + { + Name: "Notifications: Lease Period", + Description: "How long a notifier should lease a message. This is effectively how long a notification is 'owned' " + + "by a notifier, and once this period expires it will be available for lease by another notifier. Leasing " + + "is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. " + + "This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification " + + "releases the lease.", + Flag: "notifications-lease-period", + Env: "CODER_NOTIFICATIONS_LEASE_PERIOD", + Value: &c.Notifications.LeasePeriod, + Default: (time.Minute * 2).String(), + Group: &deploymentGroupNotifications, + YAML: "lease-period", + }, + { + Name: "Notifications: Lease Count", + Description: "How many notifications a notifier should lease per fetch interval.", + Flag: "notifications-lease-count", + Env: "CODER_NOTIFICATIONS_LEASE_COUNT", + Value: &c.Notifications.LeaseCount, + Default: "10", + Group: &deploymentGroupNotifications, + YAML: "lease-count", + }, + { + Name: "Notifications: Fetch Interval", + Description: "How often to query the database for queued notifications.", + Flag: "notifications-fetch-interval", + Env: "CODER_NOTIFICATIONS_FETCH_INTERVAL", + Value: &c.Notifications.FetchInterval, + Default: (time.Second * 15).String(), + Group: &deploymentGroupNotifications, + YAML: "fetch-interval", + }, + { + Name: "Notifications: Method", + Description: "Which delivery method to use (available options: 'smtp', 'webhook').", + Flag: "notifications-method", + Env: "CODER_NOTIFICATIONS_METHOD", + Value: &c.Notifications.Method, + Default: "smtp", + Group: &deploymentGroupNotifications, + YAML: "method", + }, + { + Name: "Notifications: Dispatch Timeout", + Description: "How long to wait while a notification is being sent before giving up.", + Flag: "notifications-dispatch-timeout", + Env: "CODER_NOTIFICATIONS_DISPATCH_TIMEOUT", + Value: &c.Notifications.DispatchTimeout, + Default: time.Minute.String(), + Group: &deploymentGroupNotifications, + YAML: "dispatch-timeout", + }, + { + Name: "Notifications: Email: From Address", + Description: "The sender's address to use.", + Flag: "notifications-email-from", + Env: "CODER_NOTIFICATIONS_EMAIL_FROM", + Value: &c.Notifications.SMTP.From, + Group: &deploymentGroupNotificationsEmail, + YAML: "from", + }, + { + Name: "Notifications: Email: Smarthost", + Description: "The intermediary SMTP host through which emails are sent.", + Flag: "notifications-email-smarthost", + Env: "CODER_NOTIFICATIONS_EMAIL_SMARTHOST", + Value: &c.Notifications.SMTP.Smarthost, + Group: &deploymentGroupNotificationsEmail, + YAML: "smarthost", + }, + { + Name: "Notifications: Email: Hello", + Description: "The hostname identifying the SMTP server.", + Flag: "notifications-email-hello", + Env: "CODER_NOTIFICATIONS_EMAIL_HELLO", + Default: "localhost", + Value: &c.Notifications.SMTP.Hello, + Group: &deploymentGroupNotificationsEmail, + YAML: "hello", + }, + { + Name: "Notifications: Webhook: Endpoint", + Description: "The endpoint to which to send webhooks.", + Flag: "notifications-webhook-endpoint", + Env: "CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT", + Value: &c.Notifications.Webhook.Endpoint, + Group: &deploymentGroupNotificationsWebhook, + YAML: "hello", + }, } return opts @@ -2234,6 +2451,7 @@ const ( ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentMultiOrganization Experiment = "multi-organization" // Requires organization context for interactions, default org is assumed. ExperimentCustomRoles Experiment = "custom-roles" // Allows creating runtime custom roles + ExperimentNotifications Experiment = "notifications" ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking ) diff --git a/docs/api/general.md b/docs/api/general.md index 620e3b238d7b3..b72f8ca7af643 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -253,6 +253,41 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "stackdriver": "string" }, "metrics_cache_refresh_interval": 0, + "notifications": { + "dispatch_timeout": 0, + "email": { + "from": "string", + "hello": "string", + "smarthost": { + "host": "string", + "port": "string" + } + }, + "fetch_interval": 0, + "lease_count": 0, + "lease_period": 0, + "max_send_attempts": 0, + "method": "string", + "retry_interval": 0, + "sync_buffer_size": 0, + "sync_interval": 0, + "webhook": { + "endpoint": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } + }, + "worker_count": 0 + }, "oauth2": { "github": { "allow_everyone": true, diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 305b3c0e733f6..d79931c22d40a 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1679,6 +1679,41 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "stackdriver": "string" }, "metrics_cache_refresh_interval": 0, + "notifications": { + "dispatch_timeout": 0, + "email": { + "from": "string", + "hello": "string", + "smarthost": { + "host": "string", + "port": "string" + } + }, + "fetch_interval": 0, + "lease_count": 0, + "lease_period": 0, + "max_send_attempts": 0, + "method": "string", + "retry_interval": 0, + "sync_buffer_size": 0, + "sync_interval": 0, + "webhook": { + "endpoint": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } + }, + "worker_count": 0 + }, "oauth2": { "github": { "allow_everyone": true, @@ -2052,6 +2087,41 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "stackdriver": "string" }, "metrics_cache_refresh_interval": 0, + "notifications": { + "dispatch_timeout": 0, + "email": { + "from": "string", + "hello": "string", + "smarthost": { + "host": "string", + "port": "string" + } + }, + "fetch_interval": 0, + "lease_count": 0, + "lease_period": 0, + "max_send_attempts": 0, + "method": "string", + "retry_interval": 0, + "sync_buffer_size": 0, + "sync_interval": 0, + "webhook": { + "endpoint": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } + }, + "worker_count": 0 + }, "oauth2": { "github": { "allow_everyone": true, @@ -2246,6 +2316,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `job_hang_detector_interval` | integer | false | | | | `logging` | [codersdk.LoggingConfig](#codersdkloggingconfig) | false | | | | `metrics_cache_refresh_interval` | integer | false | | | +| `notifications` | [codersdk.NotificationsConfig](#codersdknotificationsconfig) | false | | | | `oauth2` | [codersdk.OAuth2Config](#codersdkoauth2config) | false | | | | `oidc` | [codersdk.OIDCConfig](#codersdkoidcconfig) | false | | | | `pg_auth` | string | false | | | @@ -2369,6 +2440,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `multi-organization` | | `custom-roles` | | `workspace-usage` | +| `notifications` | ## codersdk.ExternalAuth @@ -2976,6 +3048,110 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `id` | string | true | | | | `username` | string | true | | | +## codersdk.NotificationsConfig + +```json +{ + "dispatch_timeout": 0, + "email": { + "from": "string", + "hello": "string", + "smarthost": { + "host": "string", + "port": "string" + } + }, + "fetch_interval": 0, + "lease_count": 0, + "lease_period": 0, + "max_send_attempts": 0, + "method": "string", + "retry_interval": 0, + "sync_buffer_size": 0, + "sync_interval": 0, + "webhook": { + "endpoint": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } + }, + "worker_count": 0 +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ------------------- | -------------------------------------------------------------------------- | -------- | ------------ | -------------- | +| `dispatch_timeout` | integer | false | | | +| `email` | [codersdk.NotificationsEmailConfig](#codersdknotificationsemailconfig) | false | | | +| `fetch_interval` | integer | false | | | +| `lease_count` | integer | false | | | +| `lease_period` | integer | false | | | +| `max_send_attempts` | integer | false | | Retries. | +| `method` | string | false | | Dispatch. | +| `retry_interval` | integer | false | | | +| `sync_buffer_size` | integer | false | | | +| `sync_interval` | integer | false | | Store updates. | +| `webhook` | [codersdk.NotificationsWebhookConfig](#codersdknotificationswebhookconfig) | false | | | +| `worker_count` | integer | false | | Queue. | + +## codersdk.NotificationsEmailConfig + +```json +{ + "from": "string", + "hello": "string", + "smarthost": { + "host": "string", + "port": "string" + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ----------- | ------------------------------------ | -------- | ------------ | --------------------------------------------------------------------- | +| `from` | string | false | | The sender's address. | +| `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.NotificationsWebhookConfig + +```json +{ + "endpoint": { + "forceQuery": true, + "fragment": "string", + "host": "string", + "omitHost": true, + "opaque": "string", + "path": "string", + "rawFragment": "string", + "rawPath": "string", + "rawQuery": "string", + "scheme": "string", + "user": {} + } +} +``` + +### Properties + +| Name | Type | Required | Restrictions | Description | +| ---------- | -------------------------- | -------- | ------------ | ----------- | +| `endpoint` | [serpent.URL](#serpenturl) | false | | | + ## codersdk.OAuth2AppEndpoints ```json diff --git a/docs/cli/server.md b/docs/cli/server.md index ea3672a1cb2d7..9a6a3e2068b28 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -1194,3 +1194,154 @@ Refresh interval for healthchecks. | Default | 15ms | The threshold for the database health check. If the median latency of the database exceeds this threshold over 5 attempts, the database is considered unhealthy. The default value is 15ms. + +### --notifications-max-send-attempts + +| | | +| ----------- | --------------------------------------------------- | +| Type | int | +| Environment | $CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS | +| YAML | notifications.max-send-attempts | +| Default | 5 | + +The upper limit of attempts to send a notification. + +### --notifications-retry-interval + +| | | +| ----------- | ------------------------------------------------ | +| Type | duration | +| Environment | $CODER_NOTIFICATIONS_RETRY_INTERVAL | +| YAML | notifications.retry-interval | +| Default | 5m0s | + +The minimum time between retries. + +### --notifications-store-sync-interval + +| | | +| ----------- | ----------------------------------------------------- | +| Type | duration | +| Environment | $CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL | +| YAML | notifications.store-sync-interval | +| Default | 2s | + +The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. + +### --notifications-store-sync-buffer-size + +| | | +| ----------- | -------------------------------------------------------- | +| Type | int | +| Environment | $CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE | +| YAML | notifications.store-sync-buffer-size | +| Default | 50 | + +The notifications system buffers message updates in memory to ease pressure on the database. This option controls how many updates are kept in memory. The lower this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. + +### --notifications-worker-count + +| | | +| ----------- | ---------------------------------------------- | +| Type | int | +| Environment | $CODER_NOTIFICATIONS_WORKER_COUNT | +| YAML | notifications.worker-count | +| Default | 2 | + +How many workers should be processing messages in the queue; increase this count if notifications are not being processed fast enough. + +### --notifications-lease-period + +| | | +| ----------- | ---------------------------------------------- | +| Type | duration | +| Environment | $CODER_NOTIFICATIONS_LEASE_PERIOD | +| YAML | notifications.lease-period | +| Default | 2m0s | + +How long a notifier should lease a message. This is effectively how long a notification is 'owned' by a notifier, and once this period expires it will be available for lease by another notifier. Leasing is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification releases the lease. + +### --notifications-lease-count + +| | | +| ----------- | --------------------------------------------- | +| Type | int | +| Environment | $CODER_NOTIFICATIONS_LEASE_COUNT | +| YAML | notifications.lease-count | +| Default | 10 | + +How many notifications a notifier should lease per fetch interval. + +### --notifications-fetch-interval + +| | | +| ----------- | ------------------------------------------------ | +| Type | duration | +| Environment | $CODER_NOTIFICATIONS_FETCH_INTERVAL | +| YAML | notifications.fetch-interval | +| Default | 15s | + +How often to query the database for queued notifications. + +### --notifications-method + +| | | +| ----------- | ---------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_METHOD | +| YAML | notifications.method | +| Default | smtp | + +Which delivery method to use (available options: 'smtp', 'webhook'). + +### --notifications-dispatch-timeout + +| | | +| ----------- | -------------------------------------------------- | +| Type | duration | +| Environment | $CODER_NOTIFICATIONS_DISPATCH_TIMEOUT | +| YAML | notifications.dispatch-timeout | +| Default | 1m0s | + +How long to wait while a notification is being sent before giving up. + +### --notifications-email-from + +| | | +| ----------- | -------------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_FROM | +| YAML | notifications.email.from | + +The sender's address to use. + +### --notifications-email-smarthost + +| | | +| ----------- | ------------------------------------------------- | +| Type | host:port | +| Environment | $CODER_NOTIFICATIONS_EMAIL_SMARTHOST | +| YAML | notifications.email.smarthost | + +The intermediary SMTP host through which emails are sent. + +### --notifications-email-hello + +| | | +| ----------- | --------------------------------------------- | +| Type | string | +| Environment | $CODER_NOTIFICATIONS_EMAIL_HELLO | +| YAML | notifications.email.hello | +| Default | localhost | + +The hostname identifying the SMTP server. + +### --notifications-webhook-endpoint + +| | | +| ----------- | -------------------------------------------------- | +| Type | url | +| Environment | $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT | +| YAML | notifications.webhook.hello | + +The endpoint to which to send webhooks. diff --git a/go.mod b/go.mod index 60953dfbb60d1..98a92a97e6a62 100644 --- a/go.mod +++ b/go.mod @@ -201,6 +201,7 @@ require ( github.com/coder/serpent v0.7.0 github.com/gomarkdown/markdown v0.0.0-20231222211730-1d6d20845b47 github.com/google/go-github/v61 v61.0.0 + github.com/mocktools/go-smtp-mock/v2 v2.3.0 ) require ( diff --git a/go.sum b/go.sum index 8fa7b0e737817..5cf54bf03673c 100644 --- a/go.sum +++ b/go.sum @@ -699,6 +699,8 @@ github.com/moby/moby v26.1.0+incompatible h1:mjepCwMH0KpCgPvrXjqqyCeTCHgzO7p9TwZ github.com/moby/moby v26.1.0+incompatible/go.mod h1:fDXVQ6+S340veQPv35CzDahGBmHsiclFwfEygB/TWMc= github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/mocktools/go-smtp-mock/v2 v2.3.0 h1:jgTDBEoQ8Kpw/fPWxy6qR2pGwtNn5j01T3Wut4xJo5Y= +github.com/mocktools/go-smtp-mock/v2 v2.3.0/go.mod h1:n8aNpDYncZHH/cZHtJKzQyeYT/Dut00RghVM+J1Ed94= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4f4b4c8333304..a3e7aebdb88bc 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -464,6 +464,7 @@ export interface DeploymentValues { readonly healthcheck?: HealthcheckConfig; readonly cli_upgrade_message?: string; readonly terms_of_service_url?: string; + readonly notifications?: NotificationsConfig; readonly config?: string; readonly write_config?: boolean; readonly address?: string; @@ -686,6 +687,34 @@ export interface MinimalUser { readonly avatar_url: string; } +// From codersdk/deployment.go +export interface NotificationsConfig { + readonly max_send_attempts: number; + readonly retry_interval: number; + readonly sync_interval: number; + readonly sync_buffer_size: number; + readonly worker_count: number; + readonly lease_period: number; + readonly lease_count: number; + readonly fetch_interval: number; + readonly method: string; + readonly dispatch_timeout: number; + readonly email: NotificationsEmailConfig; + readonly webhook: NotificationsWebhookConfig; +} + +// From codersdk/deployment.go +export interface NotificationsEmailConfig { + readonly from: string; + readonly smarthost: string; + readonly hello: string; +} + +// From codersdk/deployment.go +export interface NotificationsWebhookConfig { + readonly endpoint: string; +} + // From codersdk/oauth2.go export interface OAuth2AppEndpoints { readonly authorization: string; @@ -1961,13 +1990,15 @@ export type Experiment = | "custom-roles" | "example" | "multi-organization" - | "workspace-usage"; + | "workspace-usage" + | "notifications"; export const Experiments: Experiment[] = [ "auto-fill-parameters", "custom-roles", "example", "multi-organization", "workspace-usage", + "notifications", ]; // From codersdk/deployment.go From 4856aed2c5ccd8fc91daa21ed8763022b1742e31 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 11 Jun 2024 13:50:51 +0200 Subject: [PATCH 02/26] Fixing lint errors & minor tests Signed-off-by: Danny Kopping --- cli/testdata/coder_server_--help.golden | 62 +++++++++++++++++++ cli/testdata/server-config.yaml.golden | 59 ++++++++++++++++++ coderd/notifications/dispatch/webhook.go | 3 +- coderd/notifications/events.go | 7 +-- coderd/notifications/manager_test.go | 4 +- coderd/notifications/notifier.go | 4 +- coderd/notifications/system/system_test.go | 8 ++- codersdk/deployment.go | 30 +++++---- docs/cli/server.md | 1 + .../cli/testdata/coder_server_--help.golden | 62 +++++++++++++++++++ 10 files changed, 216 insertions(+), 24 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index acd2c62ead445..d35c857d97fd2 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -326,6 +326,68 @@ can safely ignore these settings. Minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13". +NOTIFICATIONS OPTIONS: + --notifications-dispatch-timeout duration, $CODER_NOTIFICATIONS_DISPATCH_TIMEOUT (default: 1m0s) + How long to wait while a notification is being sent before giving up. + + --notifications-fetch-interval duration, $CODER_NOTIFICATIONS_FETCH_INTERVAL (default: 15s) + How often to query the database for queued notifications. + + --notifications-lease-count int, $CODER_NOTIFICATIONS_LEASE_COUNT (default: 10) + How many notifications a notifier should lease per fetch interval. + + --notifications-lease-period duration, $CODER_NOTIFICATIONS_LEASE_PERIOD (default: 2m0s) + How long a notifier should lease a message. This is effectively how + long a notification is 'owned' by a notifier, and once this period + expires it will be available for lease by another notifier. Leasing is + important in order for multiple running notifiers to not pick the same + messages to deliver concurrently. This lease period will only expire + if a notifier shuts down ungracefully; a dispatch of the notification + releases the lease. + + --notifications-max-send-attempts int, $CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS (default: 5) + The upper limit of attempts to send a notification. + + --notifications-method string, $CODER_NOTIFICATIONS_METHOD (default: smtp) + Which delivery method to use (available options: 'smtp', 'webhook'). + + --notifications-retry-interval duration, $CODER_NOTIFICATIONS_RETRY_INTERVAL (default: 5m0s) + The minimum time between retries. + + --notifications-store-sync-buffer-size int, $CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE (default: 50) + The notifications system buffers message updates in memory to ease + pressure on the database. This option controls how many updates are + kept in memory. The lower this value the lower the change of state + inconsistency in a non-graceful shutdown - but it also increases load + on the database. It is recommended to keep this option at its default + value. + + --notifications-store-sync-interval duration, $CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL (default: 2s) + The notifications system buffers message updates in memory to ease + pressure on the database. This option controls how often it + synchronizes its state with the database. The shorter this value the + lower the change of state inconsistency in a non-graceful shutdown - + but it also increases load on the database. It is recommended to keep + this option at its default value. + + --notifications-worker-count int, $CODER_NOTIFICATIONS_WORKER_COUNT (default: 2) + How many workers should be processing messages in the queue; increase + this count if notifications are not being processed fast enough. + +NOTIFICATIONS / EMAIL OPTIONS: + --notifications-email-from string, $CODER_NOTIFICATIONS_EMAIL_FROM + The sender's address to use. + + --notifications-email-hello string, $CODER_NOTIFICATIONS_EMAIL_HELLO (default: localhost) + The hostname identifying the SMTP server. + + --notifications-email-smarthost host:port, $CODER_NOTIFICATIONS_EMAIL_SMARTHOST (default: localhost:587) + The intermediary SMTP host through which emails are sent. + +NOTIFICATIONS / WEBHOOK OPTIONS: + --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT + The endpoint to which to send webhooks. + OAUTH2 / GITHUB OPTIONS: --oauth2-github-allow-everyone bool, $CODER_OAUTH2_GITHUB_ALLOW_EVERYONE Allow all logins, setting this option means allowed orgs and teams diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index 9a34d6be56b20..df19e1a151d09 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -493,3 +493,62 @@ userQuietHoursSchedule: # compatibility reasons, this will be removed in a future release. # (default: false, type: bool) allowWorkspaceRenames: false +notifications: + # The upper limit of attempts to send a notification. + # (default: 5, type: int) + max-send-attempts: 5 + # The minimum time between retries. + # (default: 5m0s, type: duration) + retry-interval: 5m0s + # The notifications system buffers message updates in memory to ease pressure on + # the database. This option controls how often it synchronizes its state with the + # database. The shorter this value the lower the change of state inconsistency in + # a non-graceful shutdown - but it also increases load on the database. It is + # recommended to keep this option at its default value. + # (default: 2s, type: duration) + store-sync-interval: 2s + # The notifications system buffers message updates in memory to ease pressure on + # the database. This option controls how many updates are kept in memory. The + # lower this value the lower the change of state inconsistency in a non-graceful + # shutdown - but it also increases load on the database. It is recommended to keep + # this option at its default value. + # (default: 50, type: int) + store-sync-buffer-size: 50 + # How many workers should be processing messages in the queue; increase this count + # if notifications are not being processed fast enough. + # (default: 2, type: int) + worker-count: 2 + # How long a notifier should lease a message. This is effectively how long a + # notification is 'owned' by a notifier, and once this period expires it will be + # available for lease by another notifier. Leasing is important in order for + # multiple running notifiers to not pick the same messages to deliver + # concurrently. This lease period will only expire if a notifier shuts down + # ungracefully; a dispatch of the notification releases the lease. + # (default: 2m0s, type: duration) + lease-period: 2m0s + # How many notifications a notifier should lease per fetch interval. + # (default: 10, type: int) + lease-count: 10 + # How often to query the database for queued notifications. + # (default: 15s, type: duration) + fetch-interval: 15s + # Which delivery method to use (available options: 'smtp', 'webhook'). + # (default: smtp, type: string) + method: smtp + # How long to wait while a notification is being sent before giving up. + # (default: 1m0s, type: duration) + dispatch-timeout: 1m0s + email: + # The sender's address to use. + # (default: , type: string) + from: "" + # The intermediary SMTP host through which emails are sent. + # (default: localhost:587, type: host:port) + smarthost: localhost:587 + # The hostname identifying the SMTP server. + # (default: localhost, type: string) + hello: localhost + webhook: + # The endpoint to which to send webhooks. + # (default: , type: url) + hello: diff --git a/coderd/notifications/dispatch/webhook.go b/coderd/notifications/dispatch/webhook.go index 0bad248743ca2..6ebf26e1c4ef9 100644 --- a/coderd/notifications/dispatch/webhook.go +++ b/coderd/notifications/dispatch/webhook.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "errors" "io" "net/http" @@ -95,7 +96,7 @@ func (w *WebhookHandler) dispatch(msgPayload types.MessagePayload, title, body, // Body could be quite long here, let's grab the first 500B and hope it contains useful debug info. var respBody []byte respBody, err = abbreviatedRead(resp.Body, 500) - if err != nil && err != io.EOF { + if err != nil && !errors.Is(err, io.EOF) { return true, xerrors.Errorf("non-200 response (%d), read body: %w", resp.StatusCode, err) } w.log.Warn(ctx, "unsuccessful delivery", slog.F("status_code", resp.StatusCode), diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 8aae44be14c71..61e7659cbe45d 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -2,8 +2,5 @@ package notifications import "github.com/google/uuid" -var ( - // Workspaces. - TemplateWorkspaceDeleted = uuid.MustParse("'f517da0b-cdc9-410f-ab89-a86107c420ed'") - // ... -) +// Workspaces. +var TemplateWorkspaceDeleted = uuid.MustParse("'f517da0b-cdc9-410f-ab89-a86107c420ed'") // ... diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 1544b829eec41..66d08206cd893 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -86,6 +86,8 @@ func TestBufferedUpdates(t *testing.T) { } func TestBuildPayload(t *testing.T) { + t.Parallel() + // given const label = "Click here!" const url = "http://xyz.com/" @@ -198,6 +200,6 @@ func (e *enqueueInterceptor) EnqueueNotificationMessage(_ context.Context, arg d return database.NotificationMessage{}, err } -func (e *enqueueInterceptor) FetchNewMessageMetadata(_ context.Context, arg database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) { +func (e *enqueueInterceptor) FetchNewMessageMetadata(_ context.Context, _ database.FetchNewMessageMetadataParams) (database.FetchNewMessageMetadataRow, error) { return e.metadataFn(), nil } diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 7a71fe5c8bcdb..9299fd22f930c 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -170,9 +170,7 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification return nil, xerrors.Errorf("resolve handler: %w", err) } - var ( - title, body string - ) + var title, body string if title, err = render.GoTemplate(msg.TitleTemplate, payload, nil); err != nil { return nil, xerrors.Errorf("render title: %w", err) } diff --git a/coderd/notifications/system/system_test.go b/coderd/notifications/system/system_test.go index 5ddda14374e76..d8aaff9304fc9 100644 --- a/coderd/notifications/system/system_test.go +++ b/coderd/notifications/system/system_test.go @@ -5,17 +5,20 @@ import ( "testing" "time" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/system" "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/testutil" - "github.com/google/uuid" - "github.com/stretchr/testify/require" ) // TestNotifyWorkspaceDeleted tests the "public" interface for enqueueing notifications. // Calling system.NotifyWorkspaceDeleted uses the Enqueuer singleton to enqueue the notification. func TestNotifyWorkspaceDeleted(t *testing.T) { + t.Parallel() + // given manager := newFakeEnqueuer() notifications.RegisterInstance(manager) @@ -42,5 +45,6 @@ func newFakeEnqueuer() *fakeEnqueuer { func (f *fakeEnqueuer) Enqueue(context.Context, uuid.UUID, uuid.UUID, types.Labels, string, ...uuid.UUID) (*uuid.UUID, error) { f.enqueued <- true + // nolint:nilnil // Irrelevant. return nil, nil } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 47950218ff76f..42807f16926af 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2104,6 +2104,7 @@ Write out the current server config as YAML to stdout.`, Default: (time.Minute * 5).String(), Group: &deploymentGroupNotifications, YAML: "retry-interval", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, { Name: "Notifications: Store Sync Interval", @@ -2111,12 +2112,13 @@ Write out the current server config as YAML to stdout.`, "This option controls how often it synchronizes its state with the database. The shorter this value the " + "lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the " + "database. It is recommended to keep this option at its default value.", - Flag: "notifications-store-sync-interval", - Env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL", - Value: &c.Notifications.StoreSyncInterval, - Default: (time.Second * 2).String(), - Group: &deploymentGroupNotifications, - YAML: "store-sync-interval", + Flag: "notifications-store-sync-interval", + Env: "CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL", + Value: &c.Notifications.StoreSyncInterval, + Default: (time.Second * 2).String(), + Group: &deploymentGroupNotifications, + YAML: "store-sync-interval", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, { Name: "Notifications: Store Sync Buffer Size", @@ -2149,12 +2151,13 @@ Write out the current server config as YAML to stdout.`, "is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. " + "This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification " + "releases the lease.", - Flag: "notifications-lease-period", - Env: "CODER_NOTIFICATIONS_LEASE_PERIOD", - Value: &c.Notifications.LeasePeriod, - Default: (time.Minute * 2).String(), - Group: &deploymentGroupNotifications, - YAML: "lease-period", + Flag: "notifications-lease-period", + Env: "CODER_NOTIFICATIONS_LEASE_PERIOD", + Value: &c.Notifications.LeasePeriod, + Default: (time.Minute * 2).String(), + Group: &deploymentGroupNotifications, + YAML: "lease-period", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, { Name: "Notifications: Lease Count", @@ -2175,6 +2178,7 @@ Write out the current server config as YAML to stdout.`, Default: (time.Second * 15).String(), Group: &deploymentGroupNotifications, YAML: "fetch-interval", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, { Name: "Notifications: Method", @@ -2195,6 +2199,7 @@ Write out the current server config as YAML to stdout.`, Default: time.Minute.String(), Group: &deploymentGroupNotifications, YAML: "dispatch-timeout", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, { Name: "Notifications: Email: From Address", @@ -2210,6 +2215,7 @@ Write out the current server config as YAML to stdout.`, Description: "The intermediary SMTP host through which emails are sent.", Flag: "notifications-email-smarthost", Env: "CODER_NOTIFICATIONS_EMAIL_SMARTHOST", + Default: "localhost:587", // To pass validation. Value: &c.Notifications.SMTP.Smarthost, Group: &deploymentGroupNotificationsEmail, YAML: "smarthost", diff --git a/docs/cli/server.md b/docs/cli/server.md index 9a6a3e2068b28..7757d3d67cc0f 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -1322,6 +1322,7 @@ The sender's address to use. | Type | host:port | | Environment | $CODER_NOTIFICATIONS_EMAIL_SMARTHOST | | YAML | notifications.email.smarthost | +| Default | localhost:587 | The intermediary SMTP host through which emails are sent. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 2c094e84913f0..615f51e19937c 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -327,6 +327,68 @@ can safely ignore these settings. Minimum supported version of TLS. Accepted values are "tls10", "tls11", "tls12" or "tls13". +NOTIFICATIONS OPTIONS: + --notifications-dispatch-timeout duration, $CODER_NOTIFICATIONS_DISPATCH_TIMEOUT (default: 1m0s) + How long to wait while a notification is being sent before giving up. + + --notifications-fetch-interval duration, $CODER_NOTIFICATIONS_FETCH_INTERVAL (default: 15s) + How often to query the database for queued notifications. + + --notifications-lease-count int, $CODER_NOTIFICATIONS_LEASE_COUNT (default: 10) + How many notifications a notifier should lease per fetch interval. + + --notifications-lease-period duration, $CODER_NOTIFICATIONS_LEASE_PERIOD (default: 2m0s) + How long a notifier should lease a message. This is effectively how + long a notification is 'owned' by a notifier, and once this period + expires it will be available for lease by another notifier. Leasing is + important in order for multiple running notifiers to not pick the same + messages to deliver concurrently. This lease period will only expire + if a notifier shuts down ungracefully; a dispatch of the notification + releases the lease. + + --notifications-max-send-attempts int, $CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS (default: 5) + The upper limit of attempts to send a notification. + + --notifications-method string, $CODER_NOTIFICATIONS_METHOD (default: smtp) + Which delivery method to use (available options: 'smtp', 'webhook'). + + --notifications-retry-interval duration, $CODER_NOTIFICATIONS_RETRY_INTERVAL (default: 5m0s) + The minimum time between retries. + + --notifications-store-sync-buffer-size int, $CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE (default: 50) + The notifications system buffers message updates in memory to ease + pressure on the database. This option controls how many updates are + kept in memory. The lower this value the lower the change of state + inconsistency in a non-graceful shutdown - but it also increases load + on the database. It is recommended to keep this option at its default + value. + + --notifications-store-sync-interval duration, $CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL (default: 2s) + The notifications system buffers message updates in memory to ease + pressure on the database. This option controls how often it + synchronizes its state with the database. The shorter this value the + lower the change of state inconsistency in a non-graceful shutdown - + but it also increases load on the database. It is recommended to keep + this option at its default value. + + --notifications-worker-count int, $CODER_NOTIFICATIONS_WORKER_COUNT (default: 2) + How many workers should be processing messages in the queue; increase + this count if notifications are not being processed fast enough. + +NOTIFICATIONS / EMAIL OPTIONS: + --notifications-email-from string, $CODER_NOTIFICATIONS_EMAIL_FROM + The sender's address to use. + + --notifications-email-hello string, $CODER_NOTIFICATIONS_EMAIL_HELLO (default: localhost) + The hostname identifying the SMTP server. + + --notifications-email-smarthost host:port, $CODER_NOTIFICATIONS_EMAIL_SMARTHOST (default: localhost:587) + The intermediary SMTP host through which emails are sent. + +NOTIFICATIONS / WEBHOOK OPTIONS: + --notifications-webhook-endpoint url, $CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT + The endpoint to which to send webhooks. + OAUTH2 / GITHUB OPTIONS: --oauth2-github-allow-everyone bool, $CODER_OAUTH2_GITHUB_ALLOW_EVERYONE Allow all logins, setting this option means allowed orgs and teams From cda6efb4e4b2e62d2730b14845ea56632158b466 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 11 Jun 2024 15:39:19 +0200 Subject: [PATCH 03/26] Fixing dbauthz test Signed-off-by: Danny Kopping --- coderd/database/dbauthz/dbauthz_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 837cc1c9f69dc..3a9cfb318ad31 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -2486,7 +2486,8 @@ func (s *MethodTestSuite) TestSystemFunctions() { s.Run("EnqueueNotificationMessage", s.Subtest(func(db database.Store, check *expects) { // TODO: update this test once we have a specific role for notifications check.Args(database.EnqueueNotificationMessageParams{ - Method: database.NotificationMethodWebhook, + Method: database.NotificationMethodWebhook, + Payload: []byte("{}"), }).Asserts(rbac.ResourceSystem, policy.ActionCreate) })) s.Run("FetchNewMessageMetadata", s.Subtest(func(db database.Store, check *expects) { From 86f937a7550aae8f8f63a90e72c12230d661ffa9 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Tue, 11 Jun 2024 15:49:10 +0200 Subject: [PATCH 04/26] TestBufferedUpdates does not need a real db, altering test details slightly Signed-off-by: Danny Kopping --- coderd/notifications/manager_test.go | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 66d08206cd893..2a5301cb06896 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/google/uuid" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -49,7 +50,9 @@ func TestBufferedUpdates(t *testing.T) { t.Parallel() // setup - ctx, logger, db, ps := setup(t) + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + db := dbmem.New() interceptor := &bulkUpdateInterceptor{Store: db} santa := &santaHandler{} @@ -59,13 +62,16 @@ func TestBufferedUpdates(t *testing.T) { require.NoError(t, err) mgr.WithHandlers(handlers) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub.NewInMemory()}) user := coderdtest.CreateFirstUser(t, client) // given if _, err := mgr.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { require.NoError(t, err) } + if _, err := mgr.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { + require.NoError(t, err) + } if _, err := mgr.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "false"}, ""); true { require.NoError(t, err) } @@ -76,13 +82,13 @@ func TestBufferedUpdates(t *testing.T) { // then // Wait for messages to be dispatched. - require.Eventually(t, func() bool { return santa.naughty.Load() == 1 && santa.nice.Load() == 1 }, testutil.WaitMedium, testutil.IntervalFast) + require.Eventually(t, func() bool { return santa.naughty.Load() == 1 && santa.nice.Load() == 2 }, testutil.WaitMedium, testutil.IntervalFast) // Stop the manager which forces an update of buffered updates. require.NoError(t, mgr.Stop(ctx)) // Wait until both success & failure updates have been sent to the store. - require.Eventually(t, func() bool { return interceptor.failed.Load() == 1 && interceptor.sent.Load() == 1 }, testutil.WaitMedium, testutil.IntervalFast) + require.Eventually(t, func() bool { return interceptor.failed.Load() == 1 && interceptor.sent.Load() == 2 }, testutil.WaitMedium, testutil.IntervalFast) } func TestBuildPayload(t *testing.T) { From e8f1af2628f4af0b30c1f57d75fd325830fec05e Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 12 Jun 2024 12:49:04 +0200 Subject: [PATCH 05/26] Correct TestBufferedUpdates to count updated entries, use real db again Signed-off-by: Danny Kopping --- coderd/notifications/manager_test.go | 40 +++++++++++++++++++++------- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 2a5301cb06896..544e6974db3cc 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,8 +7,9 @@ import ( "testing" "time" - "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/google/uuid" + "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "golang.org/x/xerrors" @@ -50,9 +51,10 @@ func TestBufferedUpdates(t *testing.T) { t.Parallel() // setup - ctx := context.Background() - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) - db := dbmem.New() + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + ctx, logger, db, ps := setup(t) interceptor := &bulkUpdateInterceptor{Store: db} santa := &santaHandler{} @@ -62,7 +64,7 @@ func TestBufferedUpdates(t *testing.T) { require.NoError(t, err) mgr.WithHandlers(handlers) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: pubsub.NewInMemory()}) + client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) user := coderdtest.CreateFirstUser(t, client) // given @@ -88,7 +90,16 @@ func TestBufferedUpdates(t *testing.T) { require.NoError(t, mgr.Stop(ctx)) // Wait until both success & failure updates have been sent to the store. - require.Eventually(t, func() bool { return interceptor.failed.Load() == 1 && interceptor.sent.Load() == 2 }, testutil.WaitMedium, testutil.IntervalFast) + require.EventuallyWithT(t, func(ct *assert.CollectT) { + if err := interceptor.err.Load(); err != nil { + ct.Errorf("bulk update encountered error: %s", err) + // Panic when an unexpected error occurs. + ct.FailNow() + } + + assert.EqualValues(ct, 1, interceptor.failed.Load()) + assert.EqualValues(ct, 2, interceptor.sent.Load()) + }, testutil.WaitMedium, testutil.IntervalFast) } func TestBuildPayload(t *testing.T) { @@ -150,16 +161,25 @@ type bulkUpdateInterceptor struct { sent atomic.Int32 failed atomic.Int32 + err atomic.Value } func (b *bulkUpdateInterceptor) BulkMarkNotificationMessagesSent(ctx context.Context, arg database.BulkMarkNotificationMessagesSentParams) (int64, error) { - b.sent.Add(int32(len(arg.IDs))) - return b.Store.BulkMarkNotificationMessagesSent(ctx, arg) + updated, err := b.Store.BulkMarkNotificationMessagesSent(ctx, arg) + b.sent.Add(int32(updated)) + if err != nil { + b.err.Store(err) + } + return updated, err } func (b *bulkUpdateInterceptor) BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) { - b.failed.Add(int32(len(arg.IDs))) - return b.Store.BulkMarkNotificationMessagesFailed(ctx, arg) + updated, err := b.Store.BulkMarkNotificationMessagesFailed(ctx, arg) + b.failed.Add(int32(updated)) + if err != nil { + b.err.Store(err) + } + return updated, err } // santaHandler only dispatches nice messages. From a056f546ad3bbde7aeb97a730a3b1f2f95adc97f Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 27 Jun 2024 11:01:00 +0200 Subject: [PATCH 06/26] Use UUID for notifier IDs Fix test which uses coalesced fullname/username and was failing Signed-off-by: Danny Kopping --- coderd/notifications/manager.go | 8 ++++---- coderd/notifications/notifications_test.go | 4 +++- coderd/notifications/notifier.go | 11 ++++++----- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index ec7aef7cfce1a..b8e1a59993477 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -146,7 +146,7 @@ func (m *Manager) loop(ctx context.Context, notifiers int) error { for i := 0; i < notifiers; i++ { eg.Go(func() error { m.notifierMu.Lock() - n := newNotifier(ctx, m.cfg, i+1, m.log, m.store, m.handlers) + n := newNotifier(ctx, m.cfg, uuid.New(), m.log, m.store, m.handlers) m.notifiers = append(m.notifiers, n) m.notifierMu.Unlock() return n.run(ctx, success, failure) @@ -424,14 +424,14 @@ func (m *Manager) Stop(ctx context.Context) error { } type dispatchResult struct { - notifier int + notifier uuid.UUID msg uuid.UUID ts time.Time err error retryable bool } -func newSuccessfulDispatch(notifier int, msg uuid.UUID) dispatchResult { +func newSuccessfulDispatch(notifier, msg uuid.UUID) dispatchResult { return dispatchResult{ notifier: notifier, msg: msg, @@ -439,7 +439,7 @@ func newSuccessfulDispatch(notifier int, msg uuid.UUID) dispatchResult { } } -func newFailedDispatch(notifier int, msg uuid.UUID, err error, retryable bool) dispatchResult { +func newFailedDispatch(notifier, msg uuid.UUID, err error, retryable bool) dispatchResult { return dispatchResult{ notifier: notifier, msg: msg, diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 8ba00df44cc01..6fee594b84289 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -172,7 +172,8 @@ func TestWebhookDispatch(t *testing.T) { require.Equal(t, *msgID, payload.MsgID) require.Equal(t, payload.Payload.Labels, input) require.Equal(t, payload.Payload.UserEmail, "bob@coder.com") - require.Equal(t, payload.Payload.UserName, "bob") + // UserName is coalesced from `name` and `username`; in this case `name` wins. + require.Equal(t, payload.Payload.UserName, "Robert McBobbington") require.Equal(t, payload.Payload.NotificationName, "Workspace Deleted") w.WriteHeader(http.StatusOK) @@ -203,6 +204,7 @@ func TestWebhookDispatch(t *testing.T) { _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { r.Email = "bob@coder.com" r.Username = "bob" + r.Name = "Robert McBobbington" }) // when diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 9299fd22f930c..55895d27891e7 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "github.com/google/uuid" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" @@ -21,7 +22,7 @@ import ( // notifier is a consumer of the notifications_messages queue. It dequeues messages from that table and processes them // through a pipeline of fetch -> prepare -> render -> acquire handler -> deliver. type notifier struct { - id int + id uuid.UUID cfg codersdk.NotificationsConfig ctx context.Context log slog.Logger @@ -35,7 +36,7 @@ type notifier struct { handlers *HandlerRegistry } -func newNotifier(ctx context.Context, cfg codersdk.NotificationsConfig, id int, log slog.Logger, db Store, hr *HandlerRegistry) *notifier { +func newNotifier(ctx context.Context, cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr *HandlerRegistry) *notifier { return ¬ifier{ id: id, ctx: ctx, @@ -63,7 +64,7 @@ func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failu for { select { case <-ctx.Done(): - return xerrors.Errorf("notifier %d context canceled: %w", n.id, ctx.Err()) + return xerrors.Errorf("notifier %q context canceled: %w", n.id, ctx.Err()) case <-n.quit: return nil default: @@ -78,7 +79,7 @@ func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failu // Shortcut to bail out quickly if stop() has been called or the context canceled. select { case <-ctx.Done(): - return xerrors.Errorf("notifier %d context canceled: %w", n.id, ctx.Err()) + return xerrors.Errorf("notifier %q context canceled: %w", n.id, ctx.Err()) case <-n.quit: return nil case <-n.tick.C: @@ -137,7 +138,7 @@ func (n *notifier) fetch(ctx context.Context) ([]database.AcquireNotificationMes msgs, err := n.store.AcquireNotificationMessages(ctx, database.AcquireNotificationMessagesParams{ Count: int32(n.cfg.LeaseCount), MaxAttemptCount: int32(n.cfg.MaxSendAttempts), - NotifierID: int32(n.id), + NotifierID: n.id, LeaseSeconds: int32(n.cfg.LeasePeriod.Value().Seconds()), }) if err != nil { From 8c64d30910b87d8cf48a093745e77c8410e17565 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 27 Jun 2024 13:00:19 +0200 Subject: [PATCH 07/26] Small improvements from review suggestions Signed-off-by: Danny Kopping --- cli/server.go | 3 +-- coderd/notifications/dispatch/smtp.go | 7 ++++--- coderd/notifications/dispatch/webhook.go | 20 ++++---------------- coderd/notifications/events.go | 2 +- coderd/notifications/manager.go | 3 ++- 5 files changed, 12 insertions(+), 23 deletions(-) diff --git a/cli/server.go b/cli/server.go index 815db9e6f2bac..a53f30f874954 100644 --- a/cli/server.go +++ b/cli/server.go @@ -53,8 +53,6 @@ import ( "gopkg.in/yaml.v3" "tailscale.com/tailcfg" - "github.com/coder/coder/v2/coderd/database/dbauthz" - "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" "github.com/coder/coder/v2/buildinfo" @@ -66,6 +64,7 @@ import ( "github.com/coder/coder/v2/coderd/autobuild" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/awsiamrds" + "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbmetrics" "github.com/coder/coder/v2/coderd/database/dbpurge" diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index c870983d34e19..00518c8989239 100644 --- a/coderd/notifications/dispatch/smtp.go +++ b/coderd/notifications/dispatch/smtp.go @@ -27,6 +27,7 @@ import ( var ( ValidationNoFromAddressErr = xerrors.New("no 'from' address defined") + ValidationNoToAddressErr = xerrors.New("no 'to' address(es) defined") ValidationNoSmarthostHostErr = xerrors.New("smarthost 'host' is not defined, or is invalid") ValidationNoSmarthostPortErr = xerrors.New("smarthost 'port' is not defined, or is invalid") ValidationNoHelloErr = xerrors.New("'hello' not defined") @@ -282,14 +283,14 @@ func (*SMTPHandler) validateFromAddr(from string) (string, error) { return from, nil } -func (*SMTPHandler) validateToAddrs(to string) ([]string, error) { +func (s *SMTPHandler) validateToAddrs(to string) ([]string, error) { addrs, err := mail.ParseAddressList(to) if err != nil { return nil, xerrors.Errorf("parse 'to' addresses: %w", err) } if len(addrs) == 0 { - // The addresses can be non-zero but invalid. - return nil, xerrors.Errorf("no valid 'to' address(es) found, given %+v", to) + s.log.Warn(context.Background(), "no valid 'to' address(es) defined; some may be invalid", slog.F("defined", to)) + return nil, ValidationNoToAddressErr } var out []string diff --git a/coderd/notifications/dispatch/webhook.go b/coderd/notifications/dispatch/webhook.go index 6ebf26e1c4ef9..35a23be7d54e4 100644 --- a/coderd/notifications/dispatch/webhook.go +++ b/coderd/notifications/dispatch/webhook.go @@ -94,29 +94,17 @@ func (w *WebhookHandler) dispatch(msgPayload types.MessagePayload, title, body, // Handle response. if resp.StatusCode/100 > 2 { // Body could be quite long here, let's grab the first 500B and hope it contains useful debug info. - var respBody []byte - respBody, err = abbreviatedRead(resp.Body, 500) + respBody := make([]byte, 500) + lr := io.LimitReader(resp.Body, int64(len(respBody))) + n, err := lr.Read(respBody) if err != nil && !errors.Is(err, io.EOF) { return true, xerrors.Errorf("non-200 response (%d), read body: %w", resp.StatusCode, err) } w.log.Warn(ctx, "unsuccessful delivery", slog.F("status_code", resp.StatusCode), - slog.F("response", respBody), slog.F("msg_id", msgID)) + slog.F("response", respBody[:n]), slog.F("msg_id", msgID)) return true, xerrors.Errorf("non-200 response (%d)", resp.StatusCode) } return false, nil } } - -func abbreviatedRead(r io.Reader, maxLen int) ([]byte, error) { - out, err := io.ReadAll(r) - if err != nil { - return out, err - } - - if len(out) > maxLen { - return out[:maxLen], nil - } - - return out, nil -} diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index 61e7659cbe45d..d66b4bd67b675 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -3,4 +3,4 @@ package notifications import "github.com/google/uuid" // Workspaces. -var TemplateWorkspaceDeleted = uuid.MustParse("'f517da0b-cdc9-410f-ab89-a86107c420ed'") // ... +var TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed") // ... diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index b8e1a59993477..529126c320640 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "sync" + "text/template" "time" "github.com/google/uuid" @@ -51,7 +52,7 @@ type Manager struct { notifierMu sync.Mutex handlers *HandlerRegistry - helpers map[string]any + helpers template.FuncMap stopOnce sync.Once stop chan any From ac149ec1d87b65a0b40ae3bc43cb3789ac7c074e Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 27 Jun 2024 13:17:02 +0200 Subject: [PATCH 08/26] Protect notifiers from modification during Stop() Only execute the necessary code in a goroutine Signed-off-by: Danny Kopping --- coderd/notifications/manager.go | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 529126c320640..75ff6bc699c33 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -142,17 +142,18 @@ func (m *Manager) loop(ctx context.Context, notifiers int) error { failure = make(chan dispatchResult, m.cfg.StoreSyncBufferSize) ) - // Create a specific number of notifiers to run concurrently. + // Create a specific number of notifiers to run, and run them concurrently. var eg errgroup.Group + m.notifierMu.Lock() for i := 0; i < notifiers; i++ { + n := newNotifier(ctx, m.cfg, uuid.New(), m.log, m.store, m.handlers) + m.notifiers = append(m.notifiers, n) + eg.Go(func() error { - m.notifierMu.Lock() - n := newNotifier(ctx, m.cfg, uuid.New(), m.log, m.store, m.handlers) - m.notifiers = append(m.notifiers, n) - m.notifierMu.Unlock() return n.run(ctx, success, failure) }) } + m.notifierMu.Unlock() eg.Go(func() error { // Every interval, collect the messages in the channels and bulk update them in the database. @@ -374,6 +375,10 @@ func (m *Manager) bulkUpdate(ctx context.Context, success, failure <-chan dispat // Stop stops all notifiers and waits until they have stopped. func (m *Manager) Stop(ctx context.Context) error { + // Prevent notifiers from being modified while we're stopping them. + m.notifierMu.Lock() + defer m.notifierMu.Unlock() + var err error m.stopOnce.Do(func() { select { From 884fadf9469962645ae46e60b71f02e3905b2a6f Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 28 Jun 2024 17:00:47 +0200 Subject: [PATCH 09/26] Split out enqueuer as separate responsibility, get rid of singleton Signed-off-by: Danny Kopping --- cli/server.go | 22 ++- coderd/coderd.go | 4 + coderd/notifications/enqueuer.go | 128 ++++++++++++++++++ coderd/notifications/manager.go | 100 +------------- coderd/notifications/manager_test.go | 40 ++---- coderd/notifications/noop.go | 21 --- coderd/notifications/notifications_test.go | 126 ++++++----------- coderd/notifications/notifier.go | 2 +- coderd/notifications/singleton.go | 38 ------ coderd/notifications/spec.go | 2 +- coderd/notifications/system/system.go | 19 --- coderd/notifications/system/system_test.go | 50 ------- coderd/notifications/utils_test.go | 70 ++++++++++ .../provisionerdserver/provisionerdserver.go | 18 ++- .../provisionerdserver_test.go | 2 + codersdk/deployment.go | 6 +- enterprise/coderd/provisionerdaemons.go | 2 + 17 files changed, 297 insertions(+), 353 deletions(-) create mode 100644 coderd/notifications/enqueuer.go delete mode 100644 coderd/notifications/noop.go delete mode 100644 coderd/notifications/singleton.go delete mode 100644 coderd/notifications/system/system.go delete mode 100644 coderd/notifications/system/system_test.go create mode 100644 coderd/notifications/utils_test.go diff --git a/cli/server.go b/cli/server.go index a53f30f874954..6f7b8f4c49f1d 100644 --- a/cli/server.go +++ b/cli/server.go @@ -594,6 +594,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. SSHConfigOptions: configSSHOptions, }, AllowWorkspaceRenames: vals.AllowWorkspaceRenames.Value(), + NotificationsEnqueuer: notifications.NewNoopEnqueuer(), // Changed further down if notifications enabled. } if httpServers.TLSConfig != nil { options.TLSCertificates = httpServers.TLSConfig.Certificates @@ -976,20 +977,29 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. defer tracker.Close() // Manage notifications. - var notificationsManager *notifications.Manager + var ( + notificationsManager *notifications.Manager + ) if experiments.Enabled(codersdk.ExperimentNotifications) { cfg := options.DeploymentValues.Notifications - nlog := logger.Named("notifications-manager") - notificationsManager, err = notifications.NewManager(cfg, options.Database, nlog, templateHelpers(options)) + + // The enqueuer is responsible for enqueueing notifications to the given store. + enqueuer, err := notifications.NewStoreEnqueuer(cfg, options.Database, templateHelpers(options), logger.Named("notifications.enqueuer")) + if err != nil { + return xerrors.Errorf("failed to instantiate notification store enqueuer: %w", err) + } + options.NotificationsEnqueuer = enqueuer + + // The notification manager is responsible for: + // - creating notifiers and managing their lifecycles (notifiers are responsible for dequeueing/sending notifications) + // - keeping the store updated with status updates + notificationsManager, err = notifications.NewManager(cfg, options.Database, logger.Named("notifications.manager")) if err != nil { return xerrors.Errorf("failed to instantiate notification manager: %w", err) } // nolint:gocritic // TODO: create own role. notificationsManager.Run(dbauthz.AsSystemRestricted(ctx), int(cfg.WorkerCount.Value())) - notifications.RegisterInstance(notificationsManager) - } else { - notifications.RegisterInstance(notifications.NewNoopManager()) } // Wrap the server in middleware that redirects to the access URL if diff --git a/coderd/coderd.go b/coderd/coderd.go index 288eca9a4dbaf..82d60f1041137 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -55,6 +55,7 @@ import ( "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" "github.com/coder/coder/v2/coderd/metricscache" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/portsharing" "github.com/coder/coder/v2/coderd/prometheusmetrics" "github.com/coder/coder/v2/coderd/provisionerdserver" @@ -205,6 +206,8 @@ type Options struct { DatabaseRolluper *dbrollup.Rolluper // WorkspaceUsageTracker tracks workspace usage by the CLI. WorkspaceUsageTracker *workspacestats.UsageTracker + // NotificationsEnqueuer handles enqueueing notifications for delivery by SMTP, webhook, etc. + NotificationsEnqueuer notifications.Enqueuer } // @title Coder API @@ -1444,6 +1447,7 @@ func (api *API) CreateInMemoryTaggedProvisionerDaemon(dialCtx context.Context, n OIDCConfig: api.OIDCConfig, ExternalAuthConfigs: api.ExternalAuthConfigs, }, + api.NotificationsEnqueuer, ) if err != nil { return nil, err diff --git a/coderd/notifications/enqueuer.go b/coderd/notifications/enqueuer.go new file mode 100644 index 0000000000000..e242fedae2281 --- /dev/null +++ b/coderd/notifications/enqueuer.go @@ -0,0 +1,128 @@ +package notifications + +import ( + "context" + "encoding/json" + "text/template" + + "github.com/google/uuid" + "golang.org/x/xerrors" + + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/notifications/render" + "github.com/coder/coder/v2/coderd/notifications/types" + "github.com/coder/coder/v2/codersdk" +) + +type StoreEnqueuer struct { + store Store + log slog.Logger + + // TODO: expand this to allow for each notification to have custom delivery methods, or multiple, or none. + // For example, Larry might want email notifications for "workspace deleted" notifications, but Harry wants + // Slack notifications, and Mary doesn't want any. + method database.NotificationMethod + // helpers holds a map of template funcs which are used when rendering templates. These need to be passed in because + // the template funcs will return values which are inappropriately encapsulated in this struct. + helpers template.FuncMap +} + +// NewStoreEnqueuer creates an Enqueuer implementation which can persist notification messages in the store. +func NewStoreEnqueuer(cfg codersdk.NotificationsConfig, store Store, helpers template.FuncMap, log slog.Logger) (*StoreEnqueuer, error) { + var method database.NotificationMethod + if err := method.Scan(cfg.Method.String()); err != nil { + return nil, xerrors.Errorf("given notification method %q is invalid", cfg.Method) + } + + return &StoreEnqueuer{ + store: store, + log: log, + method: method, + helpers: helpers, + }, nil +} + +// Enqueue queues a notification message for later delivery. +// Messages will be dequeued by a notifier later and dispatched. +func (s *StoreEnqueuer) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { + payload, err := s.buildPayload(ctx, userID, templateID, labels) + if err != nil { + s.log.Warn(ctx, "failed to build payload", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err)) + return nil, xerrors.Errorf("enqueue notification (payload build): %w", err) + } + + input, err := json.Marshal(payload) + if err != nil { + return nil, xerrors.Errorf("failed encoding input labels: %w", err) + } + + id := uuid.New() + msg, err := s.store.EnqueueNotificationMessage(ctx, database.EnqueueNotificationMessageParams{ + ID: id, + UserID: userID, + NotificationTemplateID: templateID, + Method: s.method, + Payload: input, + Targets: targets, + CreatedBy: createdBy, + }) + if err != nil { + s.log.Warn(ctx, "failed to enqueue notification", slog.F("template_id", templateID), slog.F("input", input), slog.Error(err)) + return nil, xerrors.Errorf("enqueue notification: %w", err) + } + + s.log.Debug(ctx, "enqueued notification", slog.F("msg_id", msg.ID)) + return &id, nil +} + +// buildPayload creates the payload that the notification will for variable substitution and/or routing. +// The payload contains information about the recipient, the event that triggered the notification, and any subsequent +// actions which can be taken by the recipient. +func (s *StoreEnqueuer) buildPayload(ctx context.Context, userID uuid.UUID, templateID uuid.UUID, labels map[string]string) (*types.MessagePayload, error) { + metadata, err := s.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ + UserID: userID, + NotificationTemplateID: templateID, + }) + if err != nil { + return nil, xerrors.Errorf("new message metadata: %w", err) + } + + // Execute any templates in actions. + out, err := render.GoTemplate(string(metadata.Actions), types.MessagePayload{}, s.helpers) + if err != nil { + return nil, xerrors.Errorf("render actions: %w", err) + } + metadata.Actions = []byte(out) + + var actions []types.TemplateAction + if err = json.Unmarshal(metadata.Actions, &actions); err != nil { + return nil, xerrors.Errorf("new message metadata: parse template actions: %w", err) + } + + return &types.MessagePayload{ + Version: "1.0", + + NotificationName: metadata.NotificationName, + + UserID: metadata.UserID.String(), + UserEmail: metadata.UserEmail, + UserName: metadata.UserName, + + Actions: actions, + Labels: labels, + }, nil +} + +// NoopEnqueuer implements the Enqueuer interface but performs a noop. +type NoopEnqueuer struct{} + +// NewNoopEnqueuer builds a NoopEnqueuer which is used to fulfill the contract for enqueuing notifications, if ExperimentNotifications is not set. +func NewNoopEnqueuer() *NoopEnqueuer { + return &NoopEnqueuer{} +} + +func (*NoopEnqueuer) Enqueue(context.Context, uuid.UUID, uuid.UUID, map[string]string, string, ...uuid.UUID) (*uuid.UUID, error) { + // nolint:nilnil // irrelevant. + return nil, nil +} diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 75ff6bc699c33..42060543e1924 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -2,23 +2,18 @@ package notifications import ( "context" - "encoding/json" "sync" - "text/template" "time" "github.com/google/uuid" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" - "github.com/coder/coder/v2/coderd/notifications/render" - "github.com/coder/coder/v2/codersdk" + "cdr.dev/slog" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/notifications/dispatch" - "github.com/coder/coder/v2/coderd/notifications/types" - - "cdr.dev/slog" + "github.com/coder/coder/v2/codersdk" ) // Manager manages all notifications being enqueued and dispatched. @@ -40,10 +35,6 @@ import ( // will need an alternative mechanism for handling backpressure. type Manager struct { cfg codersdk.NotificationsConfig - // TODO: expand this to allow for each notification to have custom delivery methods, or multiple, or none. - // For example, Larry might want email notifications for "workspace deleted" notifications, but Harry wants - // Slack notifications, and Mary doesn't want any. - method database.NotificationMethod store Store log slog.Logger @@ -52,7 +43,6 @@ type Manager struct { notifierMu sync.Mutex handlers *HandlerRegistry - helpers template.FuncMap stopOnce sync.Once stop chan any @@ -63,23 +53,16 @@ type Manager struct { // // helpers is a map of template helpers which are used to customize notification messages to use global settings like // access URL etc. -func NewManager(cfg codersdk.NotificationsConfig, store Store, log slog.Logger, helpers map[string]any) (*Manager, error) { - var method database.NotificationMethod - if err := method.Scan(cfg.Method.String()); err != nil { - return nil, xerrors.Errorf("given notification method %q is invalid", cfg.Method) - } - +func NewManager(cfg codersdk.NotificationsConfig, store Store, log slog.Logger) (*Manager, error) { return &Manager{ - log: log, - cfg: cfg, - store: store, - method: method, + log: log, + cfg: cfg, + store: store, stop: make(chan any), done: make(chan any), handlers: defaultHandlers(cfg, log), - helpers: helpers, }, nil } @@ -200,77 +183,6 @@ func (m *Manager) loop(ctx context.Context, notifiers int) error { return err } -// Enqueue queues a notification message for later delivery. -// Messages will be dequeued by a notifier later and dispatched. -func (m *Manager) Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels types.Labels, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { - payload, err := m.buildPayload(ctx, userID, templateID, labels) - if err != nil { - m.log.Warn(ctx, "failed to build payload", slog.F("template_id", templateID), slog.F("user_id", userID), slog.Error(err)) - return nil, xerrors.Errorf("enqueue notification (payload build): %w", err) - } - - input, err := json.Marshal(payload) - if err != nil { - return nil, xerrors.Errorf("failed encoding input labels: %w", err) - } - - id := uuid.New() - msg, err := m.store.EnqueueNotificationMessage(ctx, database.EnqueueNotificationMessageParams{ - ID: id, - UserID: userID, - NotificationTemplateID: templateID, - Method: m.method, - Payload: input, - Targets: targets, - CreatedBy: createdBy, - }) - if err != nil { - m.log.Warn(ctx, "failed to enqueue notification", slog.F("template_id", templateID), slog.F("input", input), slog.Error(err)) - return nil, xerrors.Errorf("enqueue notification: %w", err) - } - - m.log.Debug(ctx, "enqueued notification", slog.F("msg_id", msg.ID)) - return &id, nil -} - -// buildPayload creates the payload that the notification will for variable substitution and/or routing. -// The payload contains information about the recipient, the event that triggered the notification, and any subsequent -// actions which can be taken by the recipient. -func (m *Manager) buildPayload(ctx context.Context, userID uuid.UUID, templateID uuid.UUID, labels types.Labels) (*types.MessagePayload, error) { - metadata, err := m.store.FetchNewMessageMetadata(ctx, database.FetchNewMessageMetadataParams{ - UserID: userID, - NotificationTemplateID: templateID, - }) - if err != nil { - return nil, xerrors.Errorf("new message metadata: %w", err) - } - - // Execute any templates in actions. - out, err := render.GoTemplate(string(metadata.Actions), types.MessagePayload{}, m.helpers) - if err != nil { - return nil, xerrors.Errorf("render actions: %w", err) - } - metadata.Actions = []byte(out) - - var actions []types.TemplateAction - if err = json.Unmarshal(metadata.Actions, &actions); err != nil { - return nil, xerrors.Errorf("new message metadata: parse template actions: %w", err) - } - - return &types.MessagePayload{ - Version: "1.0", - - NotificationName: metadata.NotificationName, - - UserID: metadata.UserID.String(), - UserEmail: metadata.UserEmail, - UserName: metadata.UserName, - - Actions: actions, - Labels: labels, - }, nil -} - // bulkUpdate updates messages in the store based on the given successful and failed message dispatch results. func (m *Manager) bulkUpdate(ctx context.Context, success, failure <-chan dispatchResult) { select { diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 544e6974db3cc..7cc37e0f7076d 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -18,35 +17,13 @@ import ( "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmem" + "github.com/coder/coder/v2/coderd/database/dbtestutil" "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" ) -// TestSingletonRegistration tests that a Manager which has been instantiated but not registered will error. -func TestSingletonRegistration(t *testing.T) { - t.Parallel() - - ctx := context.Background() - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) - - mgr, err := notifications.NewManager(defaultNotificationsConfig(), dbmem.New(), logger, defaultHelpers()) - require.NoError(t, err) - t.Cleanup(func() { - require.NoError(t, mgr.Stop(ctx)) - }) - - // Not registered yet. - _, err = notifications.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "") - require.ErrorIs(t, err, notifications.SingletonNotRegisteredErr) - - // Works after registering. - notifications.RegisterInstance(mgr) - _, err = notifications.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "") - require.NoError(t, err) -} - func TestBufferedUpdates(t *testing.T) { t.Parallel() @@ -60,21 +37,24 @@ func TestBufferedUpdates(t *testing.T) { santa := &santaHandler{} handlers, err := notifications.NewHandlerRegistry(santa) require.NoError(t, err) - mgr, err := notifications.NewManager(defaultNotificationsConfig(), interceptor, logger.Named("notifications"), defaultHelpers()) + cfg := defaultNotificationsConfig() + mgr, err := notifications.NewManager(cfg, interceptor, logger.Named("notifications-manager")) require.NoError(t, err) mgr.WithHandlers(handlers) + enq, err := notifications.NewStoreEnqueuer(cfg, interceptor, defaultHelpers(), logger.Named("notifications-enqueuer")) + require.NoError(t, err) client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) user := coderdtest.CreateFirstUser(t, client) // given - if _, err := mgr.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { + if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { require.NoError(t, err) } - if _, err := mgr.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { + if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { require.NoError(t, err) } - if _, err := mgr.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "false"}, ""); true { + if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "false"}, ""); true { require.NoError(t, err) } @@ -138,11 +118,11 @@ func TestBuildPayload(t *testing.T) { }) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) - mgr, err := notifications.NewManager(defaultNotificationsConfig(), interceptor, logger.Named("notifications"), helpers) + enq, err := notifications.NewStoreEnqueuer(defaultNotificationsConfig(), interceptor, helpers, logger.Named("notifications-enqueuer")) require.NoError(t, err) // when - _, err = mgr.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "test") + _, err = enq.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "test") require.NoError(t, err) // then diff --git a/coderd/notifications/noop.go b/coderd/notifications/noop.go deleted file mode 100644 index c8d3ed0a163da..0000000000000 --- a/coderd/notifications/noop.go +++ /dev/null @@ -1,21 +0,0 @@ -package notifications - -import ( - "context" - - "github.com/google/uuid" - - "github.com/coder/coder/v2/coderd/notifications/types" -) - -type NoopManager struct{} - -// NewNoopManager builds a NoopManager which is used to fulfill the contract for enqueuing notifications, if ExperimentNotifications is not set. -func NewNoopManager() *NoopManager { - return &NoopManager{} -} - -func (*NoopManager) Enqueue(context.Context, uuid.UUID, uuid.UUID, types.Labels, string, ...uuid.UUID) (*uuid.UUID, error) { - // nolint:nilnil // irrelevant. - return nil, nil -} diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 6fee594b84289..732be25b2eaf9 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -2,7 +2,6 @@ package notifications_test import ( "context" - "database/sql" "encoding/json" "fmt" "net/http" @@ -18,13 +17,9 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/dispatch" "github.com/coder/coder/v2/coderd/notifications/types" @@ -55,24 +50,25 @@ func TestBasicNotificationRoundtrip(t *testing.T) { require.NoError(t, err) cfg := defaultNotificationsConfig() - manager, err := notifications.NewManager(cfg, db, logger, defaultHelpers()) + mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) require.NoError(t, err) - manager.WithHandlers(fakeHandlers) - notifications.RegisterInstance(manager) + mgr.WithHandlers(fakeHandlers) t.Cleanup(func() { - require.NoError(t, manager.Stop(ctx)) + require.NoError(t, mgr.Stop(ctx)) }) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) user := coderdtest.CreateFirstUser(t, client) // when - sid, err := manager.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"type": "success"}, "test") + sid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"type": "success"}, "test") require.NoError(t, err) - fid, err := manager.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"type": "failure"}, "test") + fid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"type": "failure"}, "test") require.NoError(t, err) - manager.Run(ctx, 1) + mgr.Run(ctx, 1) // then require.Eventually(t, func() bool { return handler.succeeded == sid.String() }, testutil.WaitLong, testutil.IntervalMedium) @@ -106,18 +102,18 @@ func TestSMTPDispatch(t *testing.T) { Smarthost: serpent.HostPort{Host: "localhost", Port: fmt.Sprintf("%d", mockSMTPSrv.PortNumber())}, Hello: "localhost", } - handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, logger)) + handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, logger.Named("smtp"))) fakeHandlers, err := notifications.NewHandlerRegistry(handler) require.NoError(t, err) - manager, err := notifications.NewManager(cfg, db, logger, defaultHelpers()) + mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) require.NoError(t, err) - manager.WithHandlers(fakeHandlers) - - notifications.RegisterInstance(manager) + mgr.WithHandlers(fakeHandlers) t.Cleanup(func() { - require.NoError(t, manager.Stop(ctx)) + require.NoError(t, mgr.Stop(ctx)) }) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) first := coderdtest.CreateFirstUser(t, client) @@ -127,10 +123,10 @@ func TestSMTPDispatch(t *testing.T) { }) // when - msgID, err := manager.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{}, "test") + msgID, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{}, "test") require.NoError(t, err) - manager.Run(ctx, 1) + mgr.Run(ctx, 1) // then require.Eventually(t, func() bool { @@ -192,12 +188,13 @@ func TestWebhookDispatch(t *testing.T) { cfg.Webhook = codersdk.NotificationsWebhookConfig{ Endpoint: *serpent.URLOf(endpoint), } - manager, err := notifications.NewManager(cfg, db, logger, defaultHelpers()) + mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) require.NoError(t, err) - notifications.RegisterInstance(manager) t.Cleanup(func() { - require.NoError(t, manager.Stop(ctx)) + require.NoError(t, mgr.Stop(ctx)) }) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) first := coderdtest.CreateFirstUser(t, client) @@ -212,10 +209,10 @@ func TestWebhookDispatch(t *testing.T) { "a": "b", "c": "d", } - msgID, err = manager.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, input, "test") + msgID, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, input, "test") require.NoError(t, err) - manager.Run(ctx, 1) + mgr.Run(ctx, 1) // then require.Eventually(t, func() bool { return <-sent }, testutil.WaitShort, testutil.IntervalFast) @@ -269,7 +266,7 @@ func TestBackpressure(t *testing.T) { cfg.StoreSyncInterval = serpent.Duration(syncInterval) cfg.StoreSyncBufferSize = serpent.Int64(2) - handler := newDispatchInterceptor(dispatch.NewWebhookHandler(cfg.Webhook, logger)) + handler := newDispatchInterceptor(dispatch.NewWebhookHandler(cfg.Webhook, logger.Named("webhook"))) fakeHandlers, err := notifications.NewHandlerRegistry(handler) require.NoError(t, err) @@ -277,9 +274,11 @@ func TestBackpressure(t *testing.T) { storeInterceptor := &bulkUpdateInterceptor{Store: db} // given - manager, err := notifications.NewManager(cfg, storeInterceptor, logger, defaultHelpers()) + mgr, err := notifications.NewManager(cfg, storeInterceptor, logger.Named("manager")) + require.NoError(t, err) + mgr.WithHandlers(fakeHandlers) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - manager.WithHandlers(fakeHandlers) client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) first := coderdtest.CreateFirstUser(t, client) @@ -291,13 +290,13 @@ func TestBackpressure(t *testing.T) { // when const totalMessages = 30 for i := 0; i < totalMessages; i++ { - _, err = manager.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{"i": fmt.Sprintf("%d", i)}, "test") + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{"i": fmt.Sprintf("%d", i)}, "test") require.NoError(t, err) } // Start two notifiers. const notifiers = 2 - manager.Run(ctx, notifiers) + mgr.Run(ctx, notifiers) // then @@ -313,7 +312,7 @@ func TestBackpressure(t *testing.T) { // However, when we Stop() the manager the backpressure will be relieved and the buffered updates will ALL be flushed, // since all the goroutines blocked on writing updates to the buffer will be unblocked and will complete. - require.NoError(t, manager.Stop(ctx)) + require.NoError(t, mgr.Stop(ctx)) require.EqualValues(t, notifiers*batchSize, storeInterceptor.sent.Load()+storeInterceptor.failed.Load()) } @@ -372,7 +371,7 @@ func TestRetries(t *testing.T) { cfg.RetryInterval = serpent.Duration(time.Second) // query uses second-precision cfg.FetchInterval = serpent.Duration(time.Millisecond * 100) - handler := newDispatchInterceptor(dispatch.NewWebhookHandler(cfg.Webhook, logger)) + handler := newDispatchInterceptor(dispatch.NewWebhookHandler(cfg.Webhook, logger.Named("webhook"))) fakeHandlers, err := notifications.NewHandlerRegistry(handler) require.NoError(t, err) @@ -380,9 +379,14 @@ func TestRetries(t *testing.T) { storeInterceptor := &bulkUpdateInterceptor{Store: db} // given - manager, err := notifications.NewManager(cfg, storeInterceptor, logger, defaultHelpers()) + mgr, err := notifications.NewManager(cfg, storeInterceptor, logger.Named("manager")) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, mgr.Stop(ctx)) + }) + mgr.WithHandlers(fakeHandlers) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - manager.WithHandlers(fakeHandlers) client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) first := coderdtest.CreateFirstUser(t, client) @@ -393,13 +397,13 @@ func TestRetries(t *testing.T) { // when for i := 0; i < 1; i++ { - _, err = manager.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{"i": fmt.Sprintf("%d", i)}, "test") + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{"i": fmt.Sprintf("%d", i)}, "test") require.NoError(t, err) } // Start two notifiers. const notifiers = 2 - manager.Run(ctx, notifiers) + mgr.Run(ctx, notifiers) // then require.Eventually(t, func() bool { @@ -408,34 +412,6 @@ func TestRetries(t *testing.T) { }, testutil.WaitLong, testutil.IntervalFast) } -func setup(t *testing.T) (context.Context, slog.Logger, database.Store, *pubsub.PGPubsub) { - t.Helper() - - connectionURL, closeFunc, err := dbtestutil.Open() - require.NoError(t, err) - t.Cleanup(closeFunc) - - ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) - t.Cleanup(cancel) - logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) - - sqlDB, err := sql.Open("postgres", connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - require.NoError(t, sqlDB.Close()) - }) - - db := database.New(sqlDB) - ps, err := pubsub.New(ctx, logger, sqlDB, connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - require.NoError(t, ps.Close()) - }) - - // nolint:gocritic // unit tests. - return dbauthz.AsSystemRestricted(ctx), logger, db, ps -} - type fakeHandler struct { succeeded string failed string @@ -499,25 +475,3 @@ func (i *dispatchInterceptor) Dispatcher(payload types.MessagePayload, title, bo return retryable, err }, nil } - -func defaultNotificationsConfig() codersdk.NotificationsConfig { - return codersdk.NotificationsConfig{ - Method: serpent.String(database.NotificationMethodSmtp), - MaxSendAttempts: 5, - RetryInterval: serpent.Duration(time.Minute * 5), - StoreSyncInterval: serpent.Duration(time.Second * 2), - StoreSyncBufferSize: 50, - LeasePeriod: serpent.Duration(time.Minute * 2), - LeaseCount: 10, - FetchInterval: serpent.Duration(time.Second * 10), - DispatchTimeout: serpent.Duration(time.Minute), - SMTP: codersdk.NotificationsEmailConfig{}, - Webhook: codersdk.NotificationsWebhookConfig{}, - } -} - -func defaultHelpers() map[string]any { - return map[string]any{ - "base_url": func() string { return "http://test.com" }, - } -} diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index 55895d27891e7..d0b738096ec4a 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -41,7 +41,7 @@ func newNotifier(ctx context.Context, cfg codersdk.NotificationsConfig, id uuid. id: id, ctx: ctx, cfg: cfg, - log: log.Named("notifier").With(slog.F("id", id)), + log: log.Named("notifier").With(slog.F("notifier_id", id)), quit: make(chan any), done: make(chan any), tick: time.NewTicker(cfg.FetchInterval.Value()), diff --git a/coderd/notifications/singleton.go b/coderd/notifications/singleton.go deleted file mode 100644 index 1d584c5227329..0000000000000 --- a/coderd/notifications/singleton.go +++ /dev/null @@ -1,38 +0,0 @@ -package notifications - -import ( - "context" - "sync" - - "github.com/google/uuid" - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/coderd/notifications/types" -) - -var ( - singleton Enqueuer - singletonMu sync.Mutex - - SingletonNotRegisteredErr = xerrors.New("singleton not registered") -) - -// RegisterInstance receives a Manager reference to act as a Singleton. -// We use a Singleton to centralize the logic around enqueueing notifications, instead of requiring that an instance -// of the Manager be passed around the codebase. -func RegisterInstance(m Enqueuer) { - singletonMu.Lock() - defer singletonMu.Unlock() - - singleton = m -} - -// Enqueue queues a notification message for later delivery. -// This is a delegator for the underlying notifications singleton. -func Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels types.Labels, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { - if singleton == nil { - return nil, SingletonNotRegisteredErr - } - - return singleton.Enqueue(ctx, userID, templateID, labels, createdBy, targets...) -} diff --git a/coderd/notifications/spec.go b/coderd/notifications/spec.go index 3cbc19075ff0d..8f077e96905fa 100644 --- a/coderd/notifications/spec.go +++ b/coderd/notifications/spec.go @@ -32,5 +32,5 @@ type Handler interface { // Enqueuer enqueues a new notification message in the store and returns its ID, should it enqueue without failure. type Enqueuer interface { - Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels types.Labels, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) + Enqueue(ctx context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) } diff --git a/coderd/notifications/system/system.go b/coderd/notifications/system/system.go deleted file mode 100644 index 8d3be1bec68bb..0000000000000 --- a/coderd/notifications/system/system.go +++ /dev/null @@ -1,19 +0,0 @@ -package system - -import ( - "context" - - "github.com/google/uuid" - - "github.com/coder/coder/v2/coderd/notifications" - "github.com/coder/coder/v2/coderd/notifications/types" -) - -// NotifyWorkspaceDeleted notifies the given user that their workspace was deleted. -func NotifyWorkspaceDeleted(ctx context.Context, userID uuid.UUID, name, reason, createdBy string, targets ...uuid.UUID) { - _, _ = notifications.Enqueue(ctx, userID, notifications.TemplateWorkspaceDeleted, - types.Labels{ - "name": name, - "reason": reason, - }, createdBy, targets...) -} diff --git a/coderd/notifications/system/system_test.go b/coderd/notifications/system/system_test.go deleted file mode 100644 index d8aaff9304fc9..0000000000000 --- a/coderd/notifications/system/system_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package system_test - -import ( - "context" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/require" - - "github.com/coder/coder/v2/coderd/notifications" - "github.com/coder/coder/v2/coderd/notifications/system" - "github.com/coder/coder/v2/coderd/notifications/types" - "github.com/coder/coder/v2/testutil" -) - -// TestNotifyWorkspaceDeleted tests the "public" interface for enqueueing notifications. -// Calling system.NotifyWorkspaceDeleted uses the Enqueuer singleton to enqueue the notification. -func TestNotifyWorkspaceDeleted(t *testing.T) { - t.Parallel() - - // given - manager := newFakeEnqueuer() - notifications.RegisterInstance(manager) - - // when - system.NotifyWorkspaceDeleted(context.Background(), uuid.New(), "test", "reason", "test") - - // then - select { - case ok := <-manager.enqueued: - require.True(t, ok) - case <-time.After(testutil.WaitShort): - t.Fatalf("timed out") - } -} - -type fakeEnqueuer struct { - enqueued chan bool -} - -func newFakeEnqueuer() *fakeEnqueuer { - return &fakeEnqueuer{enqueued: make(chan bool, 1)} -} - -func (f *fakeEnqueuer) Enqueue(context.Context, uuid.UUID, uuid.UUID, types.Labels, string, ...uuid.UUID) (*uuid.UUID, error) { - f.enqueued <- true - // nolint:nilnil // Irrelevant. - return nil, nil -} diff --git a/coderd/notifications/utils_test.go b/coderd/notifications/utils_test.go new file mode 100644 index 0000000000000..9db77414a61b1 --- /dev/null +++ b/coderd/notifications/utils_test.go @@ -0,0 +1,70 @@ +package notifications_test + +import ( + "context" + "database/sql" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/codersdk" + "github.com/coder/coder/v2/testutil" + "github.com/coder/serpent" +) + +func setup(t *testing.T) (context.Context, slog.Logger, database.Store, *pubsub.PGPubsub) { + t.Helper() + + connectionURL, closeFunc, err := dbtestutil.Open() + require.NoError(t, err) + t.Cleanup(closeFunc) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitSuperLong) + t.Cleanup(cancel) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + + sqlDB, err := sql.Open("postgres", connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, sqlDB.Close()) + }) + + db := database.New(sqlDB) + ps, err := pubsub.New(ctx, logger, sqlDB, connectionURL) + require.NoError(t, err) + t.Cleanup(func() { + require.NoError(t, ps.Close()) + }) + + // nolint:gocritic // unit tests. + return dbauthz.AsSystemRestricted(ctx), logger, db, ps +} + +func defaultNotificationsConfig() codersdk.NotificationsConfig { + return codersdk.NotificationsConfig{ + Method: serpent.String(database.NotificationMethodSmtp), + MaxSendAttempts: 5, + RetryInterval: serpent.Duration(time.Minute * 5), + StoreSyncInterval: serpent.Duration(time.Second * 2), + StoreSyncBufferSize: 50, + LeasePeriod: serpent.Duration(time.Minute * 2), + LeaseCount: 10, + FetchInterval: serpent.Duration(time.Second * 10), + DispatchTimeout: serpent.Duration(time.Minute), + SMTP: codersdk.NotificationsEmailConfig{}, + Webhook: codersdk.NotificationsWebhookConfig{}, + } +} + +func defaultHelpers() map[string]any { + return map[string]any{ + "base_url": func() string { return "http://test.com" }, + } +} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index db919979b2efb..5d543d44ec3a0 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -32,7 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" - "github.com/coder/coder/v2/coderd/notifications/system" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/telemetry" @@ -97,6 +97,7 @@ type server struct { TemplateScheduleStore *atomic.Pointer[schedule.TemplateScheduleStore] UserQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore] DeploymentValues *codersdk.DeploymentValues + NotificationEnqueuer notifications.Enqueuer OIDCConfig promoauth.OAuth2Config @@ -151,6 +152,7 @@ func NewServer( userQuietHoursScheduleStore *atomic.Pointer[schedule.UserQuietHoursScheduleStore], deploymentValues *codersdk.DeploymentValues, options Options, + enqueuer notifications.Enqueuer, ) (proto.DRPCProvisionerDaemonServer, error) { // Fail-fast if pointers are nil if lifecycleCtx == nil { @@ -199,6 +201,7 @@ func NewServer( Database: db, Pubsub: ps, Acquirer: acquirer, + NotificationEnqueuer: enqueuer, Telemetry: tel, Tracer: tracer, QuotaCommitter: quotaCommitter, @@ -1513,7 +1516,7 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) return &proto.Empty{}, nil } -func (*server) notifyWorkspaceDeleted(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) { +func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database.Workspace, build database.WorkspaceBuild) { var reason string if build.Reason.Valid() { switch build.Reason { @@ -1526,9 +1529,16 @@ func (*server) notifyWorkspaceDeleted(ctx context.Context, workspace database.Wo } } - system.NotifyWorkspaceDeleted(ctx, workspace.OwnerID, workspace.Name, reason, "provisionerdserver", + if _, err := s.NotificationEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceDeleted, + map[string]string{ + "name": workspace.Name, + "reason": reason, + }, "provisionerdserver", // Associate this notification with all the related entities. - workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID) + workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID, + ); err != nil { + s.Logger.Warn(ctx, "failed to notify of workspace deletion", slog.Error(err)) + } } func (s *server) startTrace(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) { diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 36f2ac5f601ce..aa3e4392f9425 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -32,6 +32,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/coderd/externalauth" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" @@ -1675,6 +1676,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi HeartbeatInterval: ov.heartbeatInterval, HeartbeatFn: ov.heartbeatFn, }, + notifications.NewNoopEnqueuer(), ) require.NoError(t, err) return srv, db, ps, daemon diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 42807f16926af..af80c8f11c578 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2456,9 +2456,9 @@ const ( ExperimentExample Experiment = "example" // This isn't used for anything. ExperimentAutoFillParameters Experiment = "auto-fill-parameters" // This should not be taken out of experiments until we have redesigned the feature. ExperimentMultiOrganization Experiment = "multi-organization" // Requires organization context for interactions, default org is assumed. - ExperimentCustomRoles Experiment = "custom-roles" // Allows creating runtime custom roles - ExperimentNotifications Experiment = "notifications" - ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking + ExperimentCustomRoles Experiment = "custom-roles" // Allows creating runtime custom roles. + ExperimentNotifications Experiment = "notifications" // Sends notifications via SMTP and webhooks following certain events. + ExperimentWorkspaceUsage Experiment = "workspace-usage" // Enables the new workspace usage tracking. ) // ExperimentsAll should include all experiments that are safe for diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 827ecfffe46a6..64b3933b44014 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -27,6 +27,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" + "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" @@ -336,6 +337,7 @@ func (api *API) provisionerDaemonServe(rw http.ResponseWriter, r *http.Request) ExternalAuthConfigs: api.ExternalAuthConfigs, OIDCConfig: api.OIDCConfig, }, + notifications.NewNoopEnqueuer(), ) if err != nil { if !xerrors.Is(err, context.Canceled) { From 4e362e781f7b605f2ccfd44566c98e1dc3c9992e Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 28 Jun 2024 17:29:44 +0200 Subject: [PATCH 10/26] Remove unnecessary handler registry Signed-off-by: Danny Kopping --- coderd/notifications/manager.go | 16 +++----- coderd/notifications/manager_test.go | 10 ++--- coderd/notifications/notifications_test.go | 34 +++++++---------- coderd/notifications/notifier.go | 10 ++--- coderd/notifications/provider.go | 44 ---------------------- coderd/notifications/utils_test.go | 4 +- 6 files changed, 31 insertions(+), 87 deletions(-) delete mode 100644 coderd/notifications/provider.go diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 42060543e1924..28c4681d7ff89 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -42,7 +42,7 @@ type Manager struct { notifiers []*notifier notifierMu sync.Mutex - handlers *HandlerRegistry + handlers map[database.NotificationMethod]Handler stopOnce sync.Once stop chan any @@ -67,19 +67,15 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, log slog.Logger) } // defaultHandlers builds a set of known handlers; panics if any error occurs as these handlers should be valid at compile time. -func defaultHandlers(cfg codersdk.NotificationsConfig, log slog.Logger) *HandlerRegistry { - reg, err := NewHandlerRegistry( - dispatch.NewSMTPHandler(cfg.SMTP, log.Named("dispatcher.smtp")), - dispatch.NewWebhookHandler(cfg.Webhook, log.Named("dispatcher.webhook")), - ) - if err != nil { - panic(err) +func defaultHandlers(cfg codersdk.NotificationsConfig, log slog.Logger) map[database.NotificationMethod]Handler { + return map[database.NotificationMethod]Handler{ + database.NotificationMethodSmtp: dispatch.NewSMTPHandler(cfg.SMTP, log.Named("dispatcher.smtp")), + database.NotificationMethodWebhook: dispatch.NewWebhookHandler(cfg.Webhook, log.Named("dispatcher.webhook")), } - return reg } // WithHandlers allows for tests to inject their own handlers to verify functionality. -func (m *Manager) WithHandlers(reg *HandlerRegistry) { +func (m *Manager) WithHandlers(reg map[database.NotificationMethod]Handler) { m.handlers = reg } diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 7cc37e0f7076d..624f6263626c3 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -35,12 +35,12 @@ func TestBufferedUpdates(t *testing.T) { interceptor := &bulkUpdateInterceptor{Store: db} santa := &santaHandler{} - handlers, err := notifications.NewHandlerRegistry(santa) - require.NoError(t, err) - cfg := defaultNotificationsConfig() + cfg := defaultNotificationsConfig(database.NotificationMethodSmtp) mgr, err := notifications.NewManager(cfg, interceptor, logger.Named("notifications-manager")) require.NoError(t, err) - mgr.WithHandlers(handlers) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ + database.NotificationMethodSmtp: santa, + }) enq, err := notifications.NewStoreEnqueuer(cfg, interceptor, defaultHelpers(), logger.Named("notifications-enqueuer")) require.NoError(t, err) @@ -118,7 +118,7 @@ func TestBuildPayload(t *testing.T) { }) logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) - enq, err := notifications.NewStoreEnqueuer(defaultNotificationsConfig(), interceptor, helpers, logger.Named("notifications-enqueuer")) + enq, err := notifications.NewStoreEnqueuer(defaultNotificationsConfig(database.NotificationMethodSmtp), interceptor, helpers, logger.Named("notifications-enqueuer")) require.NoError(t, err) // when diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 732be25b2eaf9..8891658df50a1 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -43,16 +43,15 @@ func TestBasicNotificationRoundtrip(t *testing.T) { t.Skip("This test requires postgres") } ctx, logger, db, ps := setup(t) + method := database.NotificationMethodSmtp // given handler := &fakeHandler{} - fakeHandlers, err := notifications.NewHandlerRegistry(handler) - require.NoError(t, err) - cfg := defaultNotificationsConfig() + cfg := defaultNotificationsConfig(method) mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) require.NoError(t, err) - mgr.WithHandlers(fakeHandlers) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) t.Cleanup(func() { require.NoError(t, mgr.Stop(ctx)) }) @@ -96,19 +95,17 @@ func TestSMTPDispatch(t *testing.T) { // given const from = "danny@coder.com" - cfg := defaultNotificationsConfig() + method := database.NotificationMethodSmtp + cfg := defaultNotificationsConfig(method) cfg.SMTP = codersdk.NotificationsEmailConfig{ From: from, Smarthost: serpent.HostPort{Host: "localhost", Port: fmt.Sprintf("%d", mockSMTPSrv.PortNumber())}, Hello: "localhost", } handler := newDispatchInterceptor(dispatch.NewSMTPHandler(cfg.SMTP, logger.Named("smtp"))) - fakeHandlers, err := notifications.NewHandlerRegistry(handler) - require.NoError(t, err) - mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) require.NoError(t, err) - mgr.WithHandlers(fakeHandlers) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) t.Cleanup(func() { require.NoError(t, mgr.Stop(ctx)) }) @@ -183,8 +180,7 @@ func TestWebhookDispatch(t *testing.T) { require.NoError(t, err) // given - cfg := defaultNotificationsConfig() - cfg.Method = serpent.String(database.NotificationMethodWebhook) + cfg := defaultNotificationsConfig(database.NotificationMethodWebhook) cfg.Webhook = codersdk.NotificationsWebhookConfig{ Endpoint: *serpent.URLOf(endpoint), } @@ -248,8 +244,8 @@ func TestBackpressure(t *testing.T) { endpoint, err := url.Parse(server.URL) require.NoError(t, err) - cfg := defaultNotificationsConfig() - cfg.Method = serpent.String(database.NotificationMethodWebhook) + method := database.NotificationMethodWebhook + cfg := defaultNotificationsConfig(method) cfg.Webhook = codersdk.NotificationsWebhookConfig{ Endpoint: *serpent.URLOf(endpoint), } @@ -267,8 +263,6 @@ func TestBackpressure(t *testing.T) { cfg.StoreSyncBufferSize = serpent.Int64(2) handler := newDispatchInterceptor(dispatch.NewWebhookHandler(cfg.Webhook, logger.Named("webhook"))) - fakeHandlers, err := notifications.NewHandlerRegistry(handler) - require.NoError(t, err) // Intercept calls to submit the buffered updates to the store. storeInterceptor := &bulkUpdateInterceptor{Store: db} @@ -276,7 +270,7 @@ func TestBackpressure(t *testing.T) { // given mgr, err := notifications.NewManager(cfg, storeInterceptor, logger.Named("manager")) require.NoError(t, err) - mgr.WithHandlers(fakeHandlers) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) @@ -358,8 +352,8 @@ func TestRetries(t *testing.T) { endpoint, err := url.Parse(server.URL) require.NoError(t, err) - cfg := defaultNotificationsConfig() - cfg.Method = serpent.String(database.NotificationMethodWebhook) + method := database.NotificationMethodWebhook + cfg := defaultNotificationsConfig(method) cfg.Webhook = codersdk.NotificationsWebhookConfig{ Endpoint: *serpent.URLOf(endpoint), } @@ -372,8 +366,6 @@ func TestRetries(t *testing.T) { cfg.FetchInterval = serpent.Duration(time.Millisecond * 100) handler := newDispatchInterceptor(dispatch.NewWebhookHandler(cfg.Webhook, logger.Named("webhook"))) - fakeHandlers, err := notifications.NewHandlerRegistry(handler) - require.NoError(t, err) // Intercept calls to submit the buffered updates to the store. storeInterceptor := &bulkUpdateInterceptor{Store: db} @@ -384,7 +376,7 @@ func TestRetries(t *testing.T) { t.Cleanup(func() { require.NoError(t, mgr.Stop(ctx)) }) - mgr.WithHandlers(fakeHandlers) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index d0b738096ec4a..deb7c422ae413 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -33,10 +33,10 @@ type notifier struct { quit chan any done chan any - handlers *HandlerRegistry + handlers map[database.NotificationMethod]Handler } -func newNotifier(ctx context.Context, cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr *HandlerRegistry) *notifier { +func newNotifier(ctx context.Context, cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler) *notifier { return ¬ifier{ id: id, ctx: ctx, @@ -166,9 +166,9 @@ func (n *notifier) prepare(ctx context.Context, msg database.AcquireNotification return nil, xerrors.Errorf("unmarshal payload: %w", err) } - handler, err := n.handlers.Resolve(msg.Method) - if err != nil { - return nil, xerrors.Errorf("resolve handler: %w", err) + handler, ok := n.handlers[msg.Method] + if !ok { + return nil, xerrors.Errorf("failed to resolve handler %q", msg.Method) } var title, body string diff --git a/coderd/notifications/provider.go b/coderd/notifications/provider.go deleted file mode 100644 index 3f8fc7324406e..0000000000000 --- a/coderd/notifications/provider.go +++ /dev/null @@ -1,44 +0,0 @@ -package notifications - -import ( - "golang.org/x/xerrors" - - "github.com/coder/coder/v2/coderd/database" -) - -type HandlerRegistry struct { - handlers map[database.NotificationMethod]Handler -} - -func NewHandlerRegistry(handlers ...Handler) (*HandlerRegistry, error) { - reg := &HandlerRegistry{ - handlers: make(map[database.NotificationMethod]Handler), - } - - for _, h := range handlers { - if err := reg.Register(h); err != nil { - return nil, err - } - } - - return reg, nil -} - -func (p *HandlerRegistry) Register(handler Handler) error { - method := handler.NotificationMethod() - if _, found := p.handlers[method]; found { - return xerrors.Errorf("%q already registered", method) - } - - p.handlers[method] = handler - return nil -} - -func (p *HandlerRegistry) Resolve(method database.NotificationMethod) (Handler, error) { - out, found := p.handlers[method] - if !found { - return nil, xerrors.Errorf("could not resolve handler by method %q", method) - } - - return out, nil -} diff --git a/coderd/notifications/utils_test.go b/coderd/notifications/utils_test.go index 9db77414a61b1..480dc2e05906d 100644 --- a/coderd/notifications/utils_test.go +++ b/coderd/notifications/utils_test.go @@ -47,9 +47,9 @@ func setup(t *testing.T) (context.Context, slog.Logger, database.Store, *pubsub. return dbauthz.AsSystemRestricted(ctx), logger, db, ps } -func defaultNotificationsConfig() codersdk.NotificationsConfig { +func defaultNotificationsConfig(method database.NotificationMethod) codersdk.NotificationsConfig { return codersdk.NotificationsConfig{ - Method: serpent.String(database.NotificationMethodSmtp), + Method: serpent.String(method), MaxSendAttempts: 5, RetryInterval: serpent.Duration(time.Minute * 5), StoreSyncInterval: serpent.Duration(time.Second * 2), From 80972908ffa635e093cb03d2648d7b90c9bb0e62 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 28 Jun 2024 17:30:28 +0200 Subject: [PATCH 11/26] Remove unused context Signed-off-by: Danny Kopping --- coderd/notifications/manager.go | 2 +- coderd/notifications/notifier.go | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 28c4681d7ff89..d2e669aeaa43a 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -125,7 +125,7 @@ func (m *Manager) loop(ctx context.Context, notifiers int) error { var eg errgroup.Group m.notifierMu.Lock() for i := 0; i < notifiers; i++ { - n := newNotifier(ctx, m.cfg, uuid.New(), m.log, m.store, m.handlers) + n := newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers) m.notifiers = append(m.notifiers, n) eg.Go(func() error { diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index deb7c422ae413..dcdc87b5ce9be 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -16,6 +16,7 @@ import ( "github.com/coder/coder/v2/codersdk" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" ) @@ -24,7 +25,6 @@ import ( type notifier struct { id uuid.UUID cfg codersdk.NotificationsConfig - ctx context.Context log slog.Logger store Store @@ -36,10 +36,9 @@ type notifier struct { handlers map[database.NotificationMethod]Handler } -func newNotifier(ctx context.Context, cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler) *notifier { +func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger, db Store, hr map[database.NotificationMethod]Handler) *notifier { return ¬ifier{ id: id, - ctx: ctx, cfg: cfg, log: log.Named("notifier").With(slog.F("notifier_id", id)), quit: make(chan any), From 1b841ad413ab2a40abdaa025abd5d3a2f8efaad7 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 28 Jun 2024 17:44:27 +0200 Subject: [PATCH 12/26] Centralise markdown rendering Signed-off-by: Danny Kopping --- coderd/database/db2sdk/db2sdk.go | 6 +- coderd/notifications/dispatch/smtp.go | 12 +- coderd/notifications/dispatch/webhook.go | 7 +- coderd/parameter/renderer.go | 111 ------------------ coderd/{notifications => }/render/markdown.go | 13 +- .../markdown_test.go} | 12 +- coderd/templateversions.go | 6 +- coderd/userauth.go | 5 +- coderd/workspaces_test.go | 7 +- 9 files changed, 33 insertions(+), 146 deletions(-) delete mode 100644 coderd/parameter/renderer.go rename coderd/{notifications => }/render/markdown.go (91%) rename coderd/{parameter/renderer_test.go => render/markdown_test.go} (91%) diff --git a/coderd/database/db2sdk/db2sdk.go b/coderd/database/db2sdk/db2sdk.go index 6734dac38d8c3..53e0cd53ad3e9 100644 --- a/coderd/database/db2sdk/db2sdk.go +++ b/coderd/database/db2sdk/db2sdk.go @@ -16,8 +16,8 @@ import ( "tailscale.com/tailcfg" "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/parameter" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/provisionersdk/proto" @@ -106,7 +106,7 @@ func TemplateVersionParameter(param database.TemplateVersionParameter) (codersdk return codersdk.TemplateVersionParameter{}, err } - descriptionPlaintext, err := parameter.Plaintext(param.Description) + descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description) if err != nil { return codersdk.TemplateVersionParameter{}, err } @@ -244,7 +244,7 @@ func TemplateInsightsParameters(parameterRows []database.GetTemplateParameterIns return nil, err } - plaintextDescription, err := parameter.Plaintext(param.Description) + plaintextDescription, err := render.PlaintextFromMarkdown(param.Description) if err != nil { return nil, err } diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index 00518c8989239..853301c2e1b10 100644 --- a/coderd/notifications/dispatch/smtp.go +++ b/coderd/notifications/dispatch/smtp.go @@ -19,9 +19,11 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" + markdown "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/codersdk" ) @@ -57,17 +59,13 @@ func (*SMTPHandler) NotificationMethod() database.NotificationMethod { func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string) (DeliveryFunc, error) { // First render the subject & body into their own discrete strings. - subject, err := render.Plaintext(titleTmpl) + subject, err := markdown.PlaintextFromMarkdown(titleTmpl) if err != nil { return nil, xerrors.Errorf("render subject: %w", err) } - htmlBody, err := render.HTML(bodyTmpl) - if err != nil { - return nil, xerrors.Errorf("render HTML body: %w", err) - } - - plainBody, err := render.Plaintext(bodyTmpl) + htmlBody := markdown.HTMLFromMarkdown(bodyTmpl) + plainBody, err := markdown.PlaintextFromMarkdown(bodyTmpl) if err != nil { return nil, xerrors.Errorf("render plaintext body: %w", err) } diff --git a/coderd/notifications/dispatch/webhook.go b/coderd/notifications/dispatch/webhook.go index 35a23be7d54e4..79df1aef2c5c7 100644 --- a/coderd/notifications/dispatch/webhook.go +++ b/coderd/notifications/dispatch/webhook.go @@ -12,9 +12,10 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" - "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" + markdown "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/codersdk" ) @@ -49,11 +50,11 @@ func (w *WebhookHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bod return nil, xerrors.New("webhook endpoint not defined") } - title, err := render.Plaintext(titleTmpl) + title, err := markdown.PlaintextFromMarkdown(titleTmpl) if err != nil { return nil, xerrors.Errorf("render title: %w", err) } - body, err := render.Plaintext(bodyTmpl) + body, err := markdown.PlaintextFromMarkdown(bodyTmpl) if err != nil { return nil, xerrors.Errorf("render body: %w", err) } diff --git a/coderd/parameter/renderer.go b/coderd/parameter/renderer.go deleted file mode 100644 index 3767f63cd889c..0000000000000 --- a/coderd/parameter/renderer.go +++ /dev/null @@ -1,111 +0,0 @@ -package parameter - -import ( - "bytes" - "strings" - - "github.com/charmbracelet/glamour" - "github.com/charmbracelet/glamour/ansi" - gomarkdown "github.com/gomarkdown/markdown" - "github.com/gomarkdown/markdown/html" - "github.com/gomarkdown/markdown/parser" - "golang.org/x/xerrors" -) - -var plaintextStyle = ansi.StyleConfig{ - Document: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - BlockQuote: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - Paragraph: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - List: ansi.StyleList{ - StyleBlock: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - LevelIndent: 4, - }, - Heading: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - H1: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - H2: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - H3: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - H4: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - H5: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - H6: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - Strikethrough: ansi.StylePrimitive{}, - Emph: ansi.StylePrimitive{}, - Strong: ansi.StylePrimitive{}, - HorizontalRule: ansi.StylePrimitive{}, - Item: ansi.StylePrimitive{}, - Enumeration: ansi.StylePrimitive{ - BlockPrefix: ". ", - }, Task: ansi.StyleTask{}, - Link: ansi.StylePrimitive{ - Format: "({{.text}})", - }, - LinkText: ansi.StylePrimitive{ - Format: "{{.text}}", - }, - ImageText: ansi.StylePrimitive{ - Format: "{{.text}}", - }, - Image: ansi.StylePrimitive{ - Format: "({{.text}})", - }, - Code: ansi.StyleBlock{ - StylePrimitive: ansi.StylePrimitive{}, - }, - CodeBlock: ansi.StyleCodeBlock{ - StyleBlock: ansi.StyleBlock{}, - }, - Table: ansi.StyleTable{}, - DefinitionDescription: ansi.StylePrimitive{}, -} - -// Plaintext function converts the description with optional Markdown tags -// to the plaintext form. -func Plaintext(markdown string) (string, error) { - renderer, err := glamour.NewTermRenderer( - glamour.WithStandardStyle("ascii"), - glamour.WithWordWrap(0), // don't need to add spaces in the end of line - glamour.WithStyles(plaintextStyle), - ) - if err != nil { - return "", xerrors.Errorf("can't initialize the Markdown renderer: %w", err) - } - - output, err := renderer.Render(markdown) - if err != nil { - return "", xerrors.Errorf("can't render description to plaintext: %w", err) - } - defer renderer.Close() - - return strings.TrimSpace(output), nil -} - -func HTML(markdown string) string { - p := parser.NewWithExtensions(parser.CommonExtensions) - doc := p.Parse([]byte(markdown)) - renderer := html.NewRenderer(html.RendererOptions{ - Flags: html.CommonFlags | html.SkipHTML, - }, - ) - return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer))) -} diff --git a/coderd/notifications/render/markdown.go b/coderd/render/markdown.go similarity index 91% rename from coderd/notifications/render/markdown.go rename to coderd/render/markdown.go index 1e7a597edf3cc..75e6d8d1c1813 100644 --- a/coderd/notifications/render/markdown.go +++ b/coderd/render/markdown.go @@ -12,8 +12,6 @@ import ( "golang.org/x/xerrors" ) -// TODO: this is (mostly) a copy of coderd/parameter/renderer.go; unify them? - var plaintextStyle = ansi.StyleConfig{ Document: ansi.StyleBlock{ StylePrimitive: ansi.StylePrimitive{}, @@ -81,9 +79,9 @@ var plaintextStyle = ansi.StyleConfig{ DefinitionDescription: ansi.StylePrimitive{}, } -// Plaintext function converts the description with optional Markdown tags +// PlaintextFromMarkdown function converts the description with optional Markdown tags // to the plaintext form. -func Plaintext(markdown string) (string, error) { +func PlaintextFromMarkdown(markdown string) (string, error) { renderer, err := glamour.NewTermRenderer( glamour.WithStandardStyle("ascii"), glamour.WithWordWrap(0), // don't need to add spaces in the end of line @@ -102,12 +100,11 @@ func Plaintext(markdown string) (string, error) { return strings.TrimSpace(output), nil } -func HTML(markdown string) (string, error) { +func HTMLFromMarkdown(markdown string) string { p := parser.NewWithExtensions(parser.CommonExtensions | parser.HardLineBreak) // Added HardLineBreak. doc := p.Parse([]byte(markdown)) renderer := html.NewRenderer(html.RendererOptions{ Flags: html.CommonFlags | html.SkipHTML, - }, - ) - return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer))), nil + }) + return string(bytes.TrimSpace(gomarkdown.Render(doc, renderer))) } diff --git a/coderd/parameter/renderer_test.go b/coderd/render/markdown_test.go similarity index 91% rename from coderd/parameter/renderer_test.go rename to coderd/render/markdown_test.go index f0765a7a6eb14..40f3dae137633 100644 --- a/coderd/parameter/renderer_test.go +++ b/coderd/render/markdown_test.go @@ -1,11 +1,11 @@ -package parameter_test +package render_test import ( "testing" - "github.com/coder/coder/v2/coderd/parameter" - "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/render" ) func TestPlaintext(t *testing.T) { @@ -32,7 +32,7 @@ __This is bold text.__ expected := "Provide the machine image\nSee the registry (https://container.registry.blah/namespace) for options.\n\nMinion (https://octodex.github.com/images/minion.png)\n\nThis is bold text.\nThis is bold text.\nThis is italic text.\n\nBlockquotes can also be nested.\nStrikethrough.\n\n1. Lorem ipsum dolor sit amet.\n2. Consectetur adipiscing elit.\n3. Integer molestie lorem at massa.\n\nThere are also code tags!" - stripped, err := parameter.Plaintext(mdDescription) + stripped, err := render.PlaintextFromMarkdown(mdDescription) require.NoError(t, err) require.Equal(t, expected, stripped) }) @@ -42,7 +42,7 @@ __This is bold text.__ nothingChanges := "This is a simple description, so nothing changes." - stripped, err := parameter.Plaintext(nothingChanges) + stripped, err := render.PlaintextFromMarkdown(nothingChanges) require.NoError(t, err) require.Equal(t, nothingChanges, stripped) }) @@ -84,7 +84,7 @@ func TestHTML(t *testing.T) { t.Run(tt.name, func(t *testing.T) { t.Parallel() - rendered := parameter.HTML(tt.input) + rendered := render.HTMLFromMarkdown(tt.input) require.Equal(t, tt.expected, rendered) }) } diff --git a/coderd/templateversions.go b/coderd/templateversions.go index 1c9131ef0d17c..6eb2b61be0f1d 100644 --- a/coderd/templateversions.go +++ b/coderd/templateversions.go @@ -17,7 +17,6 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -26,9 +25,10 @@ import ( "github.com/coder/coder/v2/coderd/externalauth" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/parameter" "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/examples" @@ -1643,7 +1643,7 @@ func convertTemplateVersionParameter(param database.TemplateVersionParameter) (c }) } - descriptionPlaintext, err := parameter.Plaintext(param.Description) + descriptionPlaintext, err := render.PlaintextFromMarkdown(param.Description) if err != nil { return codersdk.TemplateVersionParameter{}, err } diff --git a/coderd/userauth.go b/coderd/userauth.go index c7550b89d05f7..303f8a3473bea 100644 --- a/coderd/userauth.go +++ b/coderd/userauth.go @@ -25,6 +25,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -32,9 +33,9 @@ import ( "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/httpapi" "github.com/coder/coder/v2/coderd/httpmw" - "github.com/coder/coder/v2/coderd/parameter" "github.com/coder/coder/v2/coderd/promoauth" "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/userpassword" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/cryptorand" @@ -1353,7 +1354,7 @@ func (api *API) oauthLogin(r *http.Request, params *oauthLoginParams) ([]*http.C if user.ID == uuid.Nil && !params.AllowSignups { signupsDisabledText := "Please contact your Coder administrator to request access." if api.OIDCConfig != nil && api.OIDCConfig.SignupsDisabledText != "" { - signupsDisabledText = parameter.HTML(api.OIDCConfig.SignupsDisabledText) + signupsDisabledText = render.HTMLFromMarkdown(api.OIDCConfig.SignupsDisabledText) } return httpError{ code: http.StatusForbidden, diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index e5a01df9f8edc..3c631c0dad9ba 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -20,6 +20,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/agent/agenttest" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/coderdtest" @@ -29,9 +30,9 @@ import ( "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/database/dbtime" - "github.com/coder/coder/v2/coderd/parameter" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/coderd/schedule" "github.com/coder/coder/v2/coderd/schedule/cron" "github.com/coder/coder/v2/coderd/util/ptr" @@ -2940,9 +2941,9 @@ func TestWorkspaceWithRichParameters(t *testing.T) { ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) defer cancel() - firstParameterDescriptionPlaintext, err := parameter.Plaintext(firstParameterDescription) + firstParameterDescriptionPlaintext, err := render.PlaintextFromMarkdown(firstParameterDescription) require.NoError(t, err) - secondParameterDescriptionPlaintext, err := parameter.Plaintext(secondParameterDescription) + secondParameterDescriptionPlaintext, err := render.PlaintextFromMarkdown(secondParameterDescription) require.NoError(t, err) templateRichParameters, err := client.TemplateVersionRichParameters(ctx, version.ID) From 61f5bd680c119c7eca9a2ea8e32a055cc56d37e7 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Fri, 28 Jun 2024 18:34:01 +0200 Subject: [PATCH 13/26] Appease the linter Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 9 +++++---- coderd/apidoc/swagger.json | 13 +++++++------ docs/api/schemas.md | 2 +- flake.nix | 2 +- site/src/api/typesGenerated.ts | 6 +++--- 5 files changed, 17 insertions(+), 15 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index fb3759d28370b..bc905f9ac86f8 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9380,23 +9380,24 @@ const docTemplate = `{ "auto-fill-parameters", "multi-organization", "custom-roles", + "notifications", "workspace-usage" - "notifications" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", - "ExperimentCustomRoles": "Allows creating runtime custom roles", + "ExperimentCustomRoles": "Allows creating runtime custom roles.", "ExperimentExample": "This isn't used for anything.", "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed.", - "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking" + "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", + "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", "ExperimentMultiOrganization", "ExperimentCustomRoles", + "ExperimentNotifications", "ExperimentWorkspaceUsage" - "ExperimentNotifications" ] }, "codersdk.ExternalAuth": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index ba17030e13770..c20448e2481c8 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8396,23 +8396,24 @@ "auto-fill-parameters", "multi-organization", "custom-roles", - "workspace-usage", - "notifications" + "notifications", + "workspace-usage" ], "x-enum-comments": { "ExperimentAutoFillParameters": "This should not be taken out of experiments until we have redesigned the feature.", - "ExperimentCustomRoles": "Allows creating runtime custom roles", + "ExperimentCustomRoles": "Allows creating runtime custom roles.", "ExperimentExample": "This isn't used for anything.", "ExperimentMultiOrganization": "Requires organization context for interactions, default org is assumed.", - "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking" + "ExperimentNotifications": "Sends notifications via SMTP and webhooks following certain events.", + "ExperimentWorkspaceUsage": "Enables the new workspace usage tracking." }, "x-enum-varnames": [ "ExperimentExample", "ExperimentAutoFillParameters", "ExperimentMultiOrganization", "ExperimentCustomRoles", - "ExperimentWorkspaceUsage", - "ExperimentNotifications" + "ExperimentNotifications", + "ExperimentWorkspaceUsage" ] }, "codersdk.ExternalAuth": { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d79931c22d40a..bc1a479f5d058 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2439,8 +2439,8 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `auto-fill-parameters` | | `multi-organization` | | `custom-roles` | -| `workspace-usage` | | `notifications` | +| `workspace-usage` | ## codersdk.ExternalAuth diff --git a/flake.nix b/flake.nix index ee6fbca7bd923..13da873bb59b9 100644 --- a/flake.nix +++ b/flake.nix @@ -97,7 +97,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-e0L6osJwG0EF0M3TefxaAjDvN4jvQHxTGEUEECNO1Vw="; + vendorHash = "sha256-wsvdT/x1foTehuT4DRTz00fciIlROdCWspoeXh/YuRc="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index a3e7aebdb88bc..72a3dfd714f0f 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1990,15 +1990,15 @@ export type Experiment = | "custom-roles" | "example" | "multi-organization" - | "workspace-usage" - | "notifications"; + | "notifications" + | "workspace-usage"; export const Experiments: Experiment[] = [ "auto-fill-parameters", "custom-roles", "example", "multi-organization", - "workspace-usage", "notifications", + "workspace-usage", ]; // From codersdk/deployment.go From 3c8e33b9fc16dcdeca67dace58ef71b3847fcf41 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 11:22:24 +0200 Subject: [PATCH 14/26] Only enqueue notification when not initiated by self Signed-off-by: Danny Kopping --- coderd/notifications/enqueuer.go | 1 + coderd/notifications/types/labels.go | 72 ------- .../provisionerdserver/provisionerdserver.go | 8 +- .../provisionerdserver_test.go | 181 +++++++++++++++++- 4 files changed, 187 insertions(+), 75 deletions(-) delete mode 100644 coderd/notifications/types/labels.go diff --git a/coderd/notifications/enqueuer.go b/coderd/notifications/enqueuer.go index e242fedae2281..f7b5c4655f477 100644 --- a/coderd/notifications/enqueuer.go +++ b/coderd/notifications/enqueuer.go @@ -9,6 +9,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" diff --git a/coderd/notifications/types/labels.go b/coderd/notifications/types/labels.go deleted file mode 100644 index 9126c5409a742..0000000000000 --- a/coderd/notifications/types/labels.go +++ /dev/null @@ -1,72 +0,0 @@ -package types - -import ( - "fmt" -) - -// Labels represents the metadata defined in a notification message, which will be used to augment the notification -// display and delivery. -type Labels map[string]string - -func (l Labels) GetStrict(k string) (string, bool) { - v, ok := l[k] - return v, ok -} - -func (l Labels) Get(k string) string { - return l[k] -} - -func (l Labels) Set(k, v string) { - l[k] = v -} - -func (l Labels) SetValue(k string, v fmt.Stringer) { - l[k] = v.String() -} - -// Merge combines two Labels. Keys declared on the given Labels will win over the existing Labels. -func (l Labels) Merge(m Labels) { - if len(m) == 0 { - return - } - - for k, v := range m { - l[k] = v - } -} - -func (l Labels) Delete(k string) { - delete(l, k) -} - -func (l Labels) Contains(ks ...string) bool { - for _, k := range ks { - if _, has := l[k]; !has { - return false - } - } - - return true -} - -func (l Labels) Missing(ks ...string) (out []string) { - for _, k := range ks { - if _, has := l[k]; !has { - out = append(out, k) - } - } - - return out -} - -// Cut returns the given key from the labels, deleting it from labels. -func (l Labels) Cut(k string) string { - v, ok := l.GetStrict(k) - if !ok { - return "" - } - - l.Delete(k) - return v -} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 5d543d44ec3a0..620bb7dda34ca 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -25,6 +25,7 @@ import ( protobuf "google.golang.org/protobuf/proto" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/apikey" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -1521,7 +1522,12 @@ func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database. if build.Reason.Valid() { switch build.Reason { case database.BuildReasonInitiator: - reason = "initiated by user" + if build.InitiatorID == workspace.OwnerID { + // Deletions initiated by self should not notify. + return + } + + reason = fmt.Sprintf("initiated by _%s_", build.InitiatorByUsername) case database.BuildReasonAutodelete: reason = "autodeleted due to dormancy" default: diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index aa3e4392f9425..4cbc3be4299a1 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "io" "net/url" "strings" @@ -24,6 +25,8 @@ import ( "golang.org/x/oauth2" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/serpent" + "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/audit" "github.com/coder/coder/v2/coderd/database" @@ -42,7 +45,6 @@ import ( "github.com/coder/coder/v2/provisionersdk" sdkproto "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/testutil" - "github.com/coder/serpent" ) func testTemplateScheduleStore() *atomic.Pointer[schedule.TemplateScheduleStore] { @@ -1565,6 +1567,146 @@ func TestInsertWorkspaceResource(t *testing.T) { }) } +func TestNotifications(t *testing.T) { + t.Parallel() + + t.Run("Workspace deletion", func(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + deletionReason database.BuildReason + shouldNotify bool + shouldSelfInitiate bool + }{ + { + name: "Deletion initiated by autodelete should enqueue a notification", + deletionReason: database.BuildReasonAutodelete, + shouldNotify: true, + }, + { + name: "Deletion initiated by self should not enqueue a notification", + deletionReason: database.BuildReasonInitiator, + shouldNotify: false, + shouldSelfInitiate: true, + }, + { + name: "Deletion initiated by someone else should enqueue a notification", + deletionReason: database.BuildReasonInitiator, + shouldNotify: true, + shouldSelfInitiate: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := context.Background() + notifEnq := &fakeNotificationEnqueuer{} + + srv, db, ps, pd := setup(t, false, &overrides{ + notificationEnqueuer: notifEnq, + }) + + user := dbgen.User(t, db, database.User{}) + initiator := user + if !tc.shouldSelfInitiate { + initiator = dbgen.User(t, db, database.User{}) + } + + template := dbgen.Template(t, db, database.Template{ + Name: "template", + Provisioner: database.ProvisionerTypeEcho, + OrganizationID: pd.OrganizationID, + }) + template, err := db.GetTemplateByID(ctx, template.ID) + require.NoError(t, err) + file := dbgen.File(t, db, database.File{CreatedBy: user.ID}) + workspace := dbgen.Workspace(t, db, database.Workspace{ + TemplateID: template.ID, + OwnerID: user.ID, + OrganizationID: pd.OrganizationID, + }) + version := dbgen.TemplateVersion(t, db, database.TemplateVersion{ + OrganizationID: pd.OrganizationID, + TemplateID: uuid.NullUUID{ + UUID: template.ID, + Valid: true, + }, + JobID: uuid.New(), + }) + build := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspace.ID, + TemplateVersionID: version.ID, + InitiatorID: initiator.ID, + Transition: database.WorkspaceTransitionDelete, + Reason: tc.deletionReason, + }) + job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + FileID: file.ID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + Input: must(json.Marshal(provisionerdserver.WorkspaceProvisionJob{ + WorkspaceBuildID: build.ID, + })), + OrganizationID: pd.OrganizationID, + }) + _, err = db.AcquireProvisionerJob(ctx, database.AcquireProvisionerJobParams{ + OrganizationID: pd.OrganizationID, + WorkerID: uuid.NullUUID{ + UUID: pd.ID, + Valid: true, + }, + Types: []database.ProvisionerType{database.ProvisionerTypeEcho}, + }) + require.NoError(t, err) + + publishedWorkspace := make(chan struct{}) + closeWorkspaceSubscribe, err := ps.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) { + close(publishedWorkspace) + }) + require.NoError(t, err) + defer closeWorkspaceSubscribe() + + _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ + JobId: job.ID.String(), + Type: &proto.CompletedJob_WorkspaceBuild_{ + WorkspaceBuild: &proto.CompletedJob_WorkspaceBuild{ + State: []byte{}, + Resources: []*sdkproto.Resource{{ + Name: "example", + Type: "aws_instance", + }}, + }, + }, + }) + require.NoError(t, err) + + <-publishedWorkspace + + workspace, err = db.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.True(t, workspace.Deleted) + + if tc.shouldNotify { + // Validate that the notification was sent and contained the expected values. + require.Len(t, notifEnq.sent, 1) + require.Equal(t, notifEnq.sent[0].userID, user.ID) + require.Contains(t, notifEnq.sent[0].targets, template.ID) + require.Contains(t, notifEnq.sent[0].targets, workspace.ID) + require.Contains(t, notifEnq.sent[0].targets, workspace.OrganizationID) + require.Contains(t, notifEnq.sent[0].targets, user.ID) + if tc.deletionReason == database.BuildReasonInitiator { + require.Equal(t, notifEnq.sent[0].labels["reason"], fmt.Sprintf("initiated by _%s_", initiator.Username)) + } + } else { + require.Len(t, notifEnq.sent, 0) + } + }) + } + }) +} + type overrides struct { ctx context.Context deploymentValues *codersdk.DeploymentValues @@ -1576,6 +1718,7 @@ type overrides struct { heartbeatFn func(ctx context.Context) error heartbeatInterval time.Duration auditor audit.Auditor + notificationEnqueuer notifications.Enqueuer } func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisionerDaemonServer, database.Store, pubsub.Pubsub, database.ProvisionerDaemon) { @@ -1637,6 +1780,12 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi } auditPtr.Store(&auditor) pollDur = ov.acquireJobLongPollDuration + var notifEnq notifications.Enqueuer + if ov.notificationEnqueuer != nil { + notifEnq = ov.notificationEnqueuer + } else { + notifEnq = notifications.NewNoopEnqueuer() + } daemon, err := db.UpsertProvisionerDaemon(ov.ctx, database.UpsertProvisionerDaemonParams{ Name: "test", @@ -1676,7 +1825,7 @@ func setup(t *testing.T, ignoreLogErrors bool, ov *overrides) (proto.DRPCProvisi HeartbeatInterval: ov.heartbeatInterval, HeartbeatFn: ov.heartbeatFn, }, - notifications.NewNoopEnqueuer(), + notifEnq, ) require.NoError(t, err) return srv, db, ps, daemon @@ -1780,3 +1929,31 @@ func (s *fakeStream) cancel() { s.canceled = true s.c.Broadcast() } + +type fakeNotificationEnqueuer struct { + mu sync.Mutex + sent []*notification +} + +type notification struct { + userID, templateID uuid.UUID + labels map[string]string + createdBy string + targets []uuid.UUID +} + +func (f *fakeNotificationEnqueuer) Enqueue(_ context.Context, userID, templateID uuid.UUID, labels map[string]string, createdBy string, targets ...uuid.UUID) (*uuid.UUID, error) { + f.mu.Lock() + defer f.mu.Unlock() + + f.sent = append(f.sent, ¬ification{ + userID: userID, + templateID: templateID, + labels: labels, + createdBy: createdBy, + targets: targets, + }) + + id := uuid.New() + return &id, nil +} From 757327c7ff297ec74a639502859a660118043adf Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 11:46:04 +0200 Subject: [PATCH 15/26] Hide config flags which are unlikely to be modified by operators Signed-off-by: Danny Kopping --- cli/testdata/coder_server_--help.golden | 38 ------ cli/testdata/server-config.yaml.golden | 40 +++--- codersdk/deployment.go | 128 ++++++++++-------- docs/cli/server.md | 99 ++------------ .../cli/testdata/coder_server_--help.golden | 38 ------ 5 files changed, 99 insertions(+), 244 deletions(-) diff --git a/cli/testdata/coder_server_--help.golden b/cli/testdata/coder_server_--help.golden index d35c857d97fd2..d3bd1b587260a 100644 --- a/cli/testdata/coder_server_--help.golden +++ b/cli/testdata/coder_server_--help.golden @@ -330,50 +330,12 @@ NOTIFICATIONS OPTIONS: --notifications-dispatch-timeout duration, $CODER_NOTIFICATIONS_DISPATCH_TIMEOUT (default: 1m0s) How long to wait while a notification is being sent before giving up. - --notifications-fetch-interval duration, $CODER_NOTIFICATIONS_FETCH_INTERVAL (default: 15s) - How often to query the database for queued notifications. - - --notifications-lease-count int, $CODER_NOTIFICATIONS_LEASE_COUNT (default: 10) - How many notifications a notifier should lease per fetch interval. - - --notifications-lease-period duration, $CODER_NOTIFICATIONS_LEASE_PERIOD (default: 2m0s) - How long a notifier should lease a message. This is effectively how - long a notification is 'owned' by a notifier, and once this period - expires it will be available for lease by another notifier. Leasing is - important in order for multiple running notifiers to not pick the same - messages to deliver concurrently. This lease period will only expire - if a notifier shuts down ungracefully; a dispatch of the notification - releases the lease. - --notifications-max-send-attempts int, $CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS (default: 5) The upper limit of attempts to send a notification. --notifications-method string, $CODER_NOTIFICATIONS_METHOD (default: smtp) Which delivery method to use (available options: 'smtp', 'webhook'). - --notifications-retry-interval duration, $CODER_NOTIFICATIONS_RETRY_INTERVAL (default: 5m0s) - The minimum time between retries. - - --notifications-store-sync-buffer-size int, $CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE (default: 50) - The notifications system buffers message updates in memory to ease - pressure on the database. This option controls how many updates are - kept in memory. The lower this value the lower the change of state - inconsistency in a non-graceful shutdown - but it also increases load - on the database. It is recommended to keep this option at its default - value. - - --notifications-store-sync-interval duration, $CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL (default: 2s) - The notifications system buffers message updates in memory to ease - pressure on the database. This option controls how often it - synchronizes its state with the database. The shorter this value the - lower the change of state inconsistency in a non-graceful shutdown - - but it also increases load on the database. It is recommended to keep - this option at its default value. - - --notifications-worker-count int, $CODER_NOTIFICATIONS_WORKER_COUNT (default: 2) - How many workers should be processing messages in the queue; increase - this count if notifications are not being processed fast enough. - NOTIFICATIONS / EMAIL OPTIONS: --notifications-email-from string, $CODER_NOTIFICATIONS_EMAIL_FROM The sender's address to use. diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index df19e1a151d09..d5bad479d5d14 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -494,6 +494,26 @@ userQuietHoursSchedule: # (default: false, type: bool) allowWorkspaceRenames: false notifications: + # Which delivery method to use (available options: 'smtp', 'webhook'). + # (default: smtp, type: string) + method: smtp + # How long to wait while a notification is being sent before giving up. + # (default: 1m0s, type: duration) + dispatch-timeout: 1m0s + email: + # The sender's address to use. + # (default: , type: string) + from: "" + # The intermediary SMTP host through which emails are sent. + # (default: localhost:587, type: host:port) + smarthost: localhost:587 + # The hostname identifying the SMTP server. + # (default: localhost, type: string) + hello: localhost + webhook: + # The endpoint to which to send webhooks. + # (default: , type: url) + hello: # The upper limit of attempts to send a notification. # (default: 5, type: int) max-send-attempts: 5 @@ -532,23 +552,3 @@ notifications: # How often to query the database for queued notifications. # (default: 15s, type: duration) fetch-interval: 15s - # Which delivery method to use (available options: 'smtp', 'webhook'). - # (default: smtp, type: string) - method: smtp - # How long to wait while a notification is being sent before giving up. - # (default: 1m0s, type: duration) - dispatch-timeout: 1m0s - email: - # The sender's address to use. - # (default: , type: string) - from: "" - # The intermediary SMTP host through which emails are sent. - # (default: localhost:587, type: host:port) - smarthost: localhost:587 - # The hostname identifying the SMTP server. - # (default: localhost, type: string) - hello: localhost - webhook: - # The endpoint to which to send webhooks. - # (default: , type: url) - hello: diff --git a/codersdk/deployment.go b/codersdk/deployment.go index af80c8f11c578..d5fe209524e2f 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -17,10 +17,11 @@ import ( "github.com/coreos/go-oidc/v3/oidc" + "github.com/coder/serpent" + "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/coderd/agentmetrics" "github.com/coder/coder/v2/coderd/workspaceapps/appurl" - "github.com/coder/serpent" ) // Entitlement represents whether a feature is licensed. @@ -2085,6 +2086,65 @@ Write out the current server config as YAML to stdout.`, Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), }, // Notifications Options + { + Name: "Notifications: Method", + Description: "Which delivery method to use (available options: 'smtp', 'webhook').", + Flag: "notifications-method", + Env: "CODER_NOTIFICATIONS_METHOD", + Value: &c.Notifications.Method, + Default: "smtp", + Group: &deploymentGroupNotifications, + YAML: "method", + }, + { + Name: "Notifications: Dispatch Timeout", + Description: "How long to wait while a notification is being sent before giving up.", + Flag: "notifications-dispatch-timeout", + Env: "CODER_NOTIFICATIONS_DISPATCH_TIMEOUT", + Value: &c.Notifications.DispatchTimeout, + Default: time.Minute.String(), + Group: &deploymentGroupNotifications, + YAML: "dispatch-timeout", + Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), + }, + { + Name: "Notifications: Email: From Address", + Description: "The sender's address to use.", + Flag: "notifications-email-from", + Env: "CODER_NOTIFICATIONS_EMAIL_FROM", + Value: &c.Notifications.SMTP.From, + Group: &deploymentGroupNotificationsEmail, + YAML: "from", + }, + { + Name: "Notifications: Email: Smarthost", + Description: "The intermediary SMTP host through which emails are sent.", + Flag: "notifications-email-smarthost", + Env: "CODER_NOTIFICATIONS_EMAIL_SMARTHOST", + Default: "localhost:587", // To pass validation. + Value: &c.Notifications.SMTP.Smarthost, + Group: &deploymentGroupNotificationsEmail, + YAML: "smarthost", + }, + { + Name: "Notifications: Email: Hello", + Description: "The hostname identifying the SMTP server.", + Flag: "notifications-email-hello", + Env: "CODER_NOTIFICATIONS_EMAIL_HELLO", + Default: "localhost", + Value: &c.Notifications.SMTP.Hello, + Group: &deploymentGroupNotificationsEmail, + YAML: "hello", + }, + { + Name: "Notifications: Webhook: Endpoint", + Description: "The endpoint to which to send webhooks.", + Flag: "notifications-webhook-endpoint", + Env: "CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT", + Value: &c.Notifications.Webhook.Endpoint, + Group: &deploymentGroupNotificationsWebhook, + YAML: "hello", + }, { Name: "Notifications: Max Send Attempts", Description: "The upper limit of attempts to send a notification.", @@ -2105,6 +2165,7 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupNotifications, YAML: "retry-interval", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), + Hidden: true, // Hidden because most operators should not need to modify this. }, { Name: "Notifications: Store Sync Interval", @@ -2119,6 +2180,7 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupNotifications, YAML: "store-sync-interval", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), + Hidden: true, // Hidden because most operators should not need to modify this. }, { Name: "Notifications: Store Sync Buffer Size", @@ -2132,6 +2194,7 @@ Write out the current server config as YAML to stdout.`, Default: "50", Group: &deploymentGroupNotifications, YAML: "store-sync-buffer-size", + Hidden: true, // Hidden because most operators should not need to modify this. }, { Name: "Notifications: Worker Count", @@ -2143,6 +2206,7 @@ Write out the current server config as YAML to stdout.`, Default: "2", Group: &deploymentGroupNotifications, YAML: "worker-count", + Hidden: true, // Hidden because most operators should not need to modify this. }, { Name: "Notifications: Lease Period", @@ -2158,6 +2222,7 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupNotifications, YAML: "lease-period", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), + Hidden: true, // Hidden because most operators should not need to modify this. }, { Name: "Notifications: Lease Count", @@ -2168,6 +2233,7 @@ Write out the current server config as YAML to stdout.`, Default: "10", Group: &deploymentGroupNotifications, YAML: "lease-count", + Hidden: true, // Hidden because most operators should not need to modify this. }, { Name: "Notifications: Fetch Interval", @@ -2179,65 +2245,7 @@ Write out the current server config as YAML to stdout.`, Group: &deploymentGroupNotifications, YAML: "fetch-interval", Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), - }, - { - Name: "Notifications: Method", - Description: "Which delivery method to use (available options: 'smtp', 'webhook').", - Flag: "notifications-method", - Env: "CODER_NOTIFICATIONS_METHOD", - Value: &c.Notifications.Method, - Default: "smtp", - Group: &deploymentGroupNotifications, - YAML: "method", - }, - { - Name: "Notifications: Dispatch Timeout", - Description: "How long to wait while a notification is being sent before giving up.", - Flag: "notifications-dispatch-timeout", - Env: "CODER_NOTIFICATIONS_DISPATCH_TIMEOUT", - Value: &c.Notifications.DispatchTimeout, - Default: time.Minute.String(), - Group: &deploymentGroupNotifications, - YAML: "dispatch-timeout", - Annotations: serpent.Annotations{}.Mark(annotationFormatDuration, "true"), - }, - { - Name: "Notifications: Email: From Address", - Description: "The sender's address to use.", - Flag: "notifications-email-from", - Env: "CODER_NOTIFICATIONS_EMAIL_FROM", - Value: &c.Notifications.SMTP.From, - Group: &deploymentGroupNotificationsEmail, - YAML: "from", - }, - { - Name: "Notifications: Email: Smarthost", - Description: "The intermediary SMTP host through which emails are sent.", - Flag: "notifications-email-smarthost", - Env: "CODER_NOTIFICATIONS_EMAIL_SMARTHOST", - Default: "localhost:587", // To pass validation. - Value: &c.Notifications.SMTP.Smarthost, - Group: &deploymentGroupNotificationsEmail, - YAML: "smarthost", - }, - { - Name: "Notifications: Email: Hello", - Description: "The hostname identifying the SMTP server.", - Flag: "notifications-email-hello", - Env: "CODER_NOTIFICATIONS_EMAIL_HELLO", - Default: "localhost", - Value: &c.Notifications.SMTP.Hello, - Group: &deploymentGroupNotificationsEmail, - YAML: "hello", - }, - { - Name: "Notifications: Webhook: Endpoint", - Description: "The endpoint to which to send webhooks.", - Flag: "notifications-webhook-endpoint", - Env: "CODER_NOTIFICATIONS_WEBHOOK_ENDPOINT", - Value: &c.Notifications.Webhook.Endpoint, - Group: &deploymentGroupNotificationsWebhook, - YAML: "hello", + Hidden: true, // Hidden because most operators should not need to modify this. }, } diff --git a/docs/cli/server.md b/docs/cli/server.md index 7757d3d67cc0f..b3e8da3213b3d 100644 --- a/docs/cli/server.md +++ b/docs/cli/server.md @@ -1195,94 +1195,6 @@ Refresh interval for healthchecks. The threshold for the database health check. If the median latency of the database exceeds this threshold over 5 attempts, the database is considered unhealthy. The default value is 15ms. -### --notifications-max-send-attempts - -| | | -| ----------- | --------------------------------------------------- | -| Type | int | -| Environment | $CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS | -| YAML | notifications.max-send-attempts | -| Default | 5 | - -The upper limit of attempts to send a notification. - -### --notifications-retry-interval - -| | | -| ----------- | ------------------------------------------------ | -| Type | duration | -| Environment | $CODER_NOTIFICATIONS_RETRY_INTERVAL | -| YAML | notifications.retry-interval | -| Default | 5m0s | - -The minimum time between retries. - -### --notifications-store-sync-interval - -| | | -| ----------- | ----------------------------------------------------- | -| Type | duration | -| Environment | $CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL | -| YAML | notifications.store-sync-interval | -| Default | 2s | - -The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. - -### --notifications-store-sync-buffer-size - -| | | -| ----------- | -------------------------------------------------------- | -| Type | int | -| Environment | $CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE | -| YAML | notifications.store-sync-buffer-size | -| Default | 50 | - -The notifications system buffers message updates in memory to ease pressure on the database. This option controls how many updates are kept in memory. The lower this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. - -### --notifications-worker-count - -| | | -| ----------- | ---------------------------------------------- | -| Type | int | -| Environment | $CODER_NOTIFICATIONS_WORKER_COUNT | -| YAML | notifications.worker-count | -| Default | 2 | - -How many workers should be processing messages in the queue; increase this count if notifications are not being processed fast enough. - -### --notifications-lease-period - -| | | -| ----------- | ---------------------------------------------- | -| Type | duration | -| Environment | $CODER_NOTIFICATIONS_LEASE_PERIOD | -| YAML | notifications.lease-period | -| Default | 2m0s | - -How long a notifier should lease a message. This is effectively how long a notification is 'owned' by a notifier, and once this period expires it will be available for lease by another notifier. Leasing is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification releases the lease. - -### --notifications-lease-count - -| | | -| ----------- | --------------------------------------------- | -| Type | int | -| Environment | $CODER_NOTIFICATIONS_LEASE_COUNT | -| YAML | notifications.lease-count | -| Default | 10 | - -How many notifications a notifier should lease per fetch interval. - -### --notifications-fetch-interval - -| | | -| ----------- | ------------------------------------------------ | -| Type | duration | -| Environment | $CODER_NOTIFICATIONS_FETCH_INTERVAL | -| YAML | notifications.fetch-interval | -| Default | 15s | - -How often to query the database for queued notifications. - ### --notifications-method | | | @@ -1346,3 +1258,14 @@ The hostname identifying the SMTP server. | YAML | notifications.webhook.hello | The endpoint to which to send webhooks. + +### --notifications-max-send-attempts + +| | | +| ----------- | --------------------------------------------------- | +| Type | int | +| Environment | $CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS | +| YAML | notifications.max-send-attempts | +| Default | 5 | + +The upper limit of attempts to send a notification. diff --git a/enterprise/cli/testdata/coder_server_--help.golden b/enterprise/cli/testdata/coder_server_--help.golden index 615f51e19937c..8bde8a9d3fc94 100644 --- a/enterprise/cli/testdata/coder_server_--help.golden +++ b/enterprise/cli/testdata/coder_server_--help.golden @@ -331,50 +331,12 @@ NOTIFICATIONS OPTIONS: --notifications-dispatch-timeout duration, $CODER_NOTIFICATIONS_DISPATCH_TIMEOUT (default: 1m0s) How long to wait while a notification is being sent before giving up. - --notifications-fetch-interval duration, $CODER_NOTIFICATIONS_FETCH_INTERVAL (default: 15s) - How often to query the database for queued notifications. - - --notifications-lease-count int, $CODER_NOTIFICATIONS_LEASE_COUNT (default: 10) - How many notifications a notifier should lease per fetch interval. - - --notifications-lease-period duration, $CODER_NOTIFICATIONS_LEASE_PERIOD (default: 2m0s) - How long a notifier should lease a message. This is effectively how - long a notification is 'owned' by a notifier, and once this period - expires it will be available for lease by another notifier. Leasing is - important in order for multiple running notifiers to not pick the same - messages to deliver concurrently. This lease period will only expire - if a notifier shuts down ungracefully; a dispatch of the notification - releases the lease. - --notifications-max-send-attempts int, $CODER_NOTIFICATIONS_MAX_SEND_ATTEMPTS (default: 5) The upper limit of attempts to send a notification. --notifications-method string, $CODER_NOTIFICATIONS_METHOD (default: smtp) Which delivery method to use (available options: 'smtp', 'webhook'). - --notifications-retry-interval duration, $CODER_NOTIFICATIONS_RETRY_INTERVAL (default: 5m0s) - The minimum time between retries. - - --notifications-store-sync-buffer-size int, $CODER_NOTIFICATIONS_STORE_SYNC_BUFFER_SIZE (default: 50) - The notifications system buffers message updates in memory to ease - pressure on the database. This option controls how many updates are - kept in memory. The lower this value the lower the change of state - inconsistency in a non-graceful shutdown - but it also increases load - on the database. It is recommended to keep this option at its default - value. - - --notifications-store-sync-interval duration, $CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL (default: 2s) - The notifications system buffers message updates in memory to ease - pressure on the database. This option controls how often it - synchronizes its state with the database. The shorter this value the - lower the change of state inconsistency in a non-graceful shutdown - - but it also increases load on the database. It is recommended to keep - this option at its default value. - - --notifications-worker-count int, $CODER_NOTIFICATIONS_WORKER_COUNT (default: 2) - How many workers should be processing messages in the queue; increase - this count if notifications are not being processed fast enough. - NOTIFICATIONS / EMAIL OPTIONS: --notifications-email-from string, $CODER_NOTIFICATIONS_EMAIL_FROM The sender's address to use. From 6f909ae10b541620a194325e3fd683927318f874 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 12:23:15 +0200 Subject: [PATCH 16/26] Remove unnecessary Labels struct Signed-off-by: Danny Kopping --- coderd/notifications/dispatch/smtp.go | 6 +++--- coderd/notifications/manager_test.go | 9 +++++---- coderd/notifications/notifications_test.go | 19 ++++++++++--------- coderd/notifications/types/payload.go | 4 ++-- 4 files changed, 20 insertions(+), 18 deletions(-) diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index 853301c2e1b10..0aeb678115101 100644 --- a/coderd/notifications/dispatch/smtp.go +++ b/coderd/notifications/dispatch/smtp.go @@ -71,13 +71,13 @@ func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTm } // Then, reuse these strings in the HTML & plain body templates. - payload.Labels.Set("_subject", subject) - payload.Labels.Set("_body", htmlBody) + payload.Labels["_subject"] = subject + payload.Labels["_body"] = htmlBody htmlBody, err = render.GoTemplate(htmlTemplate, payload, nil) if err != nil { return nil, xerrors.Errorf("render full html template: %w", err) } - payload.Labels.Set("_body", plainBody) + payload.Labels["_body"] = plainBody plainBody, err = render.GoTemplate(plainTemplate, payload, nil) if err != nil { return nil, xerrors.Errorf("render full plaintext template: %w", err) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 624f6263626c3..08b0dba7c96e2 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -14,6 +14,7 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbmem" @@ -48,13 +49,13 @@ func TestBufferedUpdates(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) // given - if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { + if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, ""); true { require.NoError(t, err) } - if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "true"}, ""); true { + if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, ""); true { require.NoError(t, err) } - if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"nice": "false"}, ""); true { + if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "false"}, ""); true { require.NoError(t, err) } @@ -174,7 +175,7 @@ func (*santaHandler) NotificationMethod() database.NotificationMethod { func (s *santaHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) { return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { - if payload.Labels.Get("nice") != "true" { + if payload.Labels["nice"] != "true" { s.naughty.Add(1) return false, xerrors.New("be nice") } diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 8891658df50a1..591e297513ee1 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -17,6 +17,8 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" + "github.com/coder/serpent" + "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/database/dbtestutil" @@ -25,7 +27,6 @@ import ( "github.com/coder/coder/v2/coderd/notifications/types" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" - "github.com/coder/serpent" ) func TestMain(m *testing.M) { @@ -62,9 +63,9 @@ func TestBasicNotificationRoundtrip(t *testing.T) { user := coderdtest.CreateFirstUser(t, client) // when - sid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"type": "success"}, "test") + sid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "success"}, "test") require.NoError(t, err) - fid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, types.Labels{"type": "failure"}, "test") + fid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "failure"}, "test") require.NoError(t, err) mgr.Run(ctx, 1) @@ -120,7 +121,7 @@ func TestSMTPDispatch(t *testing.T) { }) // when - msgID, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{}, "test") + msgID, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{}, "test") require.NoError(t, err) mgr.Run(ctx, 1) @@ -150,7 +151,7 @@ func TestWebhookDispatch(t *testing.T) { var ( msgID *uuid.UUID - input types.Labels + input map[string]string ) sent := make(chan bool, 1) @@ -201,7 +202,7 @@ func TestWebhookDispatch(t *testing.T) { }) // when - input = types.Labels{ + input = map[string]string{ "a": "b", "c": "d", } @@ -284,7 +285,7 @@ func TestBackpressure(t *testing.T) { // when const totalMessages = 30 for i := 0; i < totalMessages; i++ { - _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{"i": fmt.Sprintf("%d", i)}, "test") + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"i": fmt.Sprintf("%d", i)}, "test") require.NoError(t, err) } @@ -389,7 +390,7 @@ func TestRetries(t *testing.T) { // when for i := 0; i < 1; i++ { - _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, types.Labels{"i": fmt.Sprintf("%d", i)}, "test") + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"i": fmt.Sprintf("%d", i)}, "test") require.NoError(t, err) } @@ -415,7 +416,7 @@ func (*fakeHandler) NotificationMethod() database.NotificationMethod { func (f *fakeHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) { return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { - if payload.Labels.Get("type") == "success" { + if payload.Labels["type"] == "success" { f.succeeded = msgID.String() } else { f.failed = msgID.String() diff --git a/coderd/notifications/types/payload.go b/coderd/notifications/types/payload.go index a33512b602a3a..a3067f456c18e 100644 --- a/coderd/notifications/types/payload.go +++ b/coderd/notifications/types/payload.go @@ -14,6 +14,6 @@ type MessagePayload struct { UserEmail string `json:"user_email"` UserName string `json:"user_name"` - Actions []TemplateAction `json:"actions"` - Labels Labels `json:"labels"` + Actions []TemplateAction `json:"actions"` + Labels map[string]string `json:"labels"` } From 36698c52f825600bea548019b95dd7cd14c416d9 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 12:24:23 +0200 Subject: [PATCH 17/26] Enable experiment as safe Signed-off-by: Danny Kopping --- codersdk/deployment.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codersdk/deployment.go b/codersdk/deployment.go index d5fe209524e2f..12c5bbd890210 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2473,7 +2473,7 @@ const ( // users to opt-in to via --experimental='*'. // Experiments that are not ready for consumption by all users should // not be included here and will be essentially hidden. -var ExperimentsAll = Experiments{} +var ExperimentsAll = Experiments{ExperimentNotifications} // Experiments is a list of experiments. // Multiple experiments may be enabled at the same time. From c5701a637536e1eb0c97abb688d88b178d4c64b6 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 14:02:45 +0200 Subject: [PATCH 18/26] Correcting bad refactor Signed-off-by: Danny Kopping --- coderd/notifications/notifier.go | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index dcdc87b5ce9be..e2edf8b98b5a8 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -204,9 +204,8 @@ func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotification // instead of canceling the context. // // In the case of backpressure (i.e. the success/failure channels are full because the database is slow), - // and this caused delivery timeout (CODER_NOTIFICATIONS_DISPATCH_TIMEOUT), we can't append any more updates to - // the channels otherwise this, too, will block. - if xerrors.Is(err, context.Canceled) || xerrors.Is(err, context.DeadlineExceeded) { + // we can't append any more updates to the channels otherwise this, too, will block. + if xerrors.Is(err, context.Canceled) { return err } From 9d4c31237fc130fd89ae0150454f2fd08b7a5345 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 14:30:43 +0200 Subject: [PATCH 19/26] Initialize Enqueuer on API startup Signed-off-by: Danny Kopping --- coderd/coderd.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/coderd/coderd.go b/coderd/coderd.go index 82d60f1041137..d08f78cafeafa 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -37,6 +37,8 @@ import ( "tailscale.com/util/singleflight" "cdr.dev/slog" + "github.com/coder/serpent" + agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/buildinfo" _ "github.com/coder/coder/v2/coderd/apidoc" // Used for swagger docs. @@ -76,7 +78,6 @@ import ( "github.com/coder/coder/v2/provisionersdk" "github.com/coder/coder/v2/site" "github.com/coder/coder/v2/tailnet" - "github.com/coder/serpent" ) // We must only ever instantiate one httpSwagger.Handler because of a data race @@ -390,6 +391,10 @@ func New(options *Options) *API { ) } + if options.NotificationsEnqueuer == nil { + options.NotificationsEnqueuer = notifications.NewNoopEnqueuer() + } + ctx, cancel := context.WithCancel(context.Background()) r := chi.NewRouter() From 9380d8e6ee0e89b68a5a71dd352c37a447c48f04 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 15:06:13 +0200 Subject: [PATCH 20/26] Only start one notifier since all dispatches are concurrent anyway Signed-off-by: Danny Kopping --- cli/server.go | 11 +-- cli/testdata/server-config.yaml.golden | 8 +-- coderd/apidoc/docs.go | 5 +- coderd/apidoc/swagger.json | 5 +- coderd/notifications/manager.go | 84 +++++++++------------- coderd/notifications/manager_test.go | 15 +++- coderd/notifications/notifications_test.go | 27 ++++--- codersdk/deployment.go | 15 +--- docs/api/general.md | 3 +- docs/api/schemas.md | 12 ++-- site/src/api/typesGenerated.ts | 1 - 11 files changed, 77 insertions(+), 109 deletions(-) diff --git a/cli/server.go b/cli/server.go index 6f7b8f4c49f1d..6a35e8aaa95ea 100644 --- a/cli/server.go +++ b/cli/server.go @@ -55,6 +55,11 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" + "github.com/coder/pretty" + "github.com/coder/retry" + "github.com/coder/serpent" + "github.com/coder/wgtunnel/tunnelsdk" + "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/clilog" "github.com/coder/coder/v2/cli/cliui" @@ -99,10 +104,6 @@ import ( "github.com/coder/coder/v2/provisionersdk" sdkproto "github.com/coder/coder/v2/provisionersdk/proto" "github.com/coder/coder/v2/tailnet" - "github.com/coder/pretty" - "github.com/coder/retry" - "github.com/coder/serpent" - "github.com/coder/wgtunnel/tunnelsdk" ) func createOIDCConfig(ctx context.Context, vals *codersdk.DeploymentValues) (*coderd.OIDCConfig, error) { @@ -999,7 +1000,7 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. } // nolint:gocritic // TODO: create own role. - notificationsManager.Run(dbauthz.AsSystemRestricted(ctx), int(cfg.WorkerCount.Value())) + notificationsManager.Run(dbauthz.AsSystemRestricted(ctx)) } // Wrap the server in middleware that redirects to the access URL if diff --git a/cli/testdata/server-config.yaml.golden b/cli/testdata/server-config.yaml.golden index d5bad479d5d14..b00fda26c2a7d 100644 --- a/cli/testdata/server-config.yaml.golden +++ b/cli/testdata/server-config.yaml.golden @@ -534,10 +534,6 @@ notifications: # this option at its default value. # (default: 50, type: int) store-sync-buffer-size: 50 - # How many workers should be processing messages in the queue; increase this count - # if notifications are not being processed fast enough. - # (default: 2, type: int) - worker-count: 2 # How long a notifier should lease a message. This is effectively how long a # notification is 'owned' by a notifier, and once this period expires it will be # available for lease by another notifier. Leasing is important in order for @@ -547,8 +543,8 @@ notifications: # (default: 2m0s, type: duration) lease-period: 2m0s # How many notifications a notifier should lease per fetch interval. - # (default: 10, type: int) - lease-count: 10 + # (default: 20, type: int) + lease-count: 20 # How often to query the database for queued notifications. # (default: 15s, type: duration) fetch-interval: 15s diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index bc905f9ac86f8..82a7f60e02d16 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9947,6 +9947,7 @@ const docTemplate = `{ "type": "integer" }, "lease_period": { + "description": "Queue.", "type": "integer" }, "max_send_attempts": { @@ -9969,10 +9970,6 @@ const docTemplate = `{ }, "webhook": { "$ref": "#/definitions/codersdk.NotificationsWebhookConfig" - }, - "worker_count": { - "description": "Queue.", - "type": "integer" } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index c20448e2481c8..503435822f05d 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8916,6 +8916,7 @@ "type": "integer" }, "lease_period": { + "description": "Queue.", "type": "integer" }, "max_send_attempts": { @@ -8938,10 +8939,6 @@ }, "webhook": { "$ref": "#/definitions/codersdk.NotificationsWebhookConfig" - }, - "worker_count": { - "description": "Queue.", - "type": "integer" } } }, diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index d2e669aeaa43a..1f7da234f71e6 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -18,10 +18,10 @@ import ( // Manager manages all notifications being enqueued and dispatched. // -// Manager maintains a group of notifiers: these consume the queue of notification messages in the store. +// Manager maintains a notifier: this consumes the queue of notification messages in the store. // -// Notifiers dequeue messages from the store _CODER_NOTIFICATIONS_LEASE_COUNT_ at a time and concurrently "dispatch" these messages, meaning they are -// sent by their respective methods (email, webhook, etc). +// The notifier dequeues messages from the store _CODER_NOTIFICATIONS_LEASE_COUNT_ at a time and concurrently "dispatches" +// these messages, meaning they are sent by their respective methods (email, webhook, etc). // // To reduce load on the store, successful and failed dispatches are accumulated in two separate buffers (success/failure) // of size CODER_NOTIFICATIONS_STORE_SYNC_INTERVAL in the Manager, and updates are sent to the store about which messages @@ -30,20 +30,19 @@ import ( // sent but they start failing too quickly, the buffers (receive channels) will fill up and block senders, which will // slow down the dispatch rate. // -// NOTE: The above backpressure mechanism only works if all notifiers live within the same process, which may not be true -// forever, such as if we split notifiers out into separate targets for greater processing throughput; in this case we -// will need an alternative mechanism for handling backpressure. +// NOTE: The above backpressure mechanism only works within the same process, which may not be true forever, such as if +// we split notifiers out into separate targets for greater processing throughput; in this case we will need an +// alternative mechanism for handling backpressure. type Manager struct { cfg codersdk.NotificationsConfig store Store log slog.Logger - notifiers []*notifier - notifierMu sync.Mutex - + notifier *notifier handlers map[database.NotificationMethod]Handler + runOnce sync.Once stopOnce sync.Once stop chan any done chan any @@ -81,25 +80,28 @@ func (m *Manager) WithHandlers(reg map[database.NotificationMethod]Handler) { // Run initiates the control loop in the background, which spawns a given number of notifier goroutines. // Manager requires system-level permissions to interact with the store. -func (m *Manager) Run(ctx context.Context, notifiers int) { - // Closes when Stop() is called or context is canceled. - go func() { - err := m.loop(ctx, notifiers) - if err != nil { - m.log.Error(ctx, "notification manager stopped with error", slog.Error(err)) - } - }() +// Run is only intended to be run once. +func (m *Manager) Run(ctx context.Context) { + m.runOnce.Do(func() { + // Closes when Stop() is called or context is canceled. + go func() { + err := m.loop(ctx) + if err != nil { + m.log.Error(ctx, "notification manager stopped with error", slog.Error(err)) + } + }() + }) } // loop contains the main business logic of the notification manager. It is responsible for subscribing to notification -// events, creating notifiers, and publishing bulk dispatch result updates to the store. -func (m *Manager) loop(ctx context.Context, notifiers int) error { +// events, creating a notifier, and publishing bulk dispatch result updates to the store. +func (m *Manager) loop(ctx context.Context) error { defer func() { close(m.done) m.log.Info(context.Background(), "notification manager stopped") }() - // Caught a terminal signal before notifiers were created, exit immediately. + // Caught a terminal signal before notifier was created, exit immediately. select { case <-m.stop: m.log.Warn(ctx, "gracefully stopped") @@ -121,21 +123,17 @@ func (m *Manager) loop(ctx context.Context, notifiers int) error { failure = make(chan dispatchResult, m.cfg.StoreSyncBufferSize) ) - // Create a specific number of notifiers to run, and run them concurrently. var eg errgroup.Group - m.notifierMu.Lock() - for i := 0; i < notifiers; i++ { - n := newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers) - m.notifiers = append(m.notifiers, n) - - eg.Go(func() error { - return n.run(ctx, success, failure) - }) - } - m.notifierMu.Unlock() + // Create a notifier to run concurrently, which will handle dequeueing and dispatching notifications. + m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers) eg.Go(func() error { - // Every interval, collect the messages in the channels and bulk update them in the database. + return m.notifier.run(ctx, success, failure) + }) + + // Periodically flush notification state changes to the store. + eg.Go(func() error { + // Every interval, collect the messages in the channels and bulk update them in the store. tick := time.NewTicker(m.cfg.StoreSyncInterval.Value()) defer tick.Stop() for { @@ -281,12 +279,8 @@ func (m *Manager) bulkUpdate(ctx context.Context, success, failure <-chan dispat wg.Wait() } -// Stop stops all notifiers and waits until they have stopped. +// Stop stops the notifier and waits until it has stopped. func (m *Manager) Stop(ctx context.Context) error { - // Prevent notifiers from being modified while we're stopping them. - m.notifierMu.Lock() - defer m.notifierMu.Unlock() - var err error m.stopOnce.Do(func() { select { @@ -298,22 +292,14 @@ func (m *Manager) Stop(ctx context.Context) error { m.log.Info(context.Background(), "graceful stop requested") - // If the notifiers haven't been started, we don't need to wait for anything. + // If the notifier hasn't been started, we don't need to wait for anything. // This is only really during testing when we want to enqueue messages only but not deliver them. - if len(m.notifiers) == 0 { + if m.notifier == nil { close(m.done) + } else { + m.notifier.stop() } - // Stop all notifiers. - var eg errgroup.Group - for _, n := range m.notifiers { - eg.Go(func() error { - n.stop() - return nil - }) - } - _ = eg.Wait() - // Signal the stop channel to cause loop to exit. close(m.stop) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 08b0dba7c96e2..bd7a0c7d421dc 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -60,7 +60,7 @@ func TestBufferedUpdates(t *testing.T) { } // when - mgr.Run(ctx, 1) + mgr.Run(ctx) // then @@ -137,6 +137,19 @@ func TestBuildPayload(t *testing.T) { } } +func TestStopBeforeRun(t *testing.T) { + ctx := context.Background() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + mgr, err := notifications.NewManager(defaultNotificationsConfig(database.NotificationMethodSmtp), dbmem.New(), logger.Named("notifications-manager")) + require.NoError(t, err) + + // Call stop before notifier is started with Run(). + require.Eventually(t, func() bool { + assert.NoError(t, mgr.Stop(ctx)) + return true + }, testutil.WaitShort, testutil.IntervalFast) +} + type bulkUpdateInterceptor struct { notifications.Store diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 591e297513ee1..688324270f7c9 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -68,7 +68,7 @@ func TestBasicNotificationRoundtrip(t *testing.T) { fid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "failure"}, "test") require.NoError(t, err) - mgr.Run(ctx, 1) + mgr.Run(ctx) // then require.Eventually(t, func() bool { return handler.succeeded == sid.String() }, testutil.WaitLong, testutil.IntervalMedium) @@ -124,7 +124,7 @@ func TestSMTPDispatch(t *testing.T) { msgID, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{}, "test") require.NoError(t, err) - mgr.Run(ctx, 1) + mgr.Run(ctx) // then require.Eventually(t, func() bool { @@ -209,7 +209,7 @@ func TestWebhookDispatch(t *testing.T) { msgID, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, input, "test") require.NoError(t, err) - mgr.Run(ctx, 1) + mgr.Run(ctx) // then require.Eventually(t, func() bool { return <-sent }, testutil.WaitShort, testutil.IntervalFast) @@ -289,26 +289,25 @@ func TestBackpressure(t *testing.T) { require.NoError(t, err) } - // Start two notifiers. - const notifiers = 2 - mgr.Run(ctx, notifiers) + // Start the notifier. + mgr.Run(ctx) // then // Wait for 3 fetch intervals, then check progress. time.Sleep(fetchInterval * 3) - // We expect the notifiers will have dispatched ONLY the initial batch of messages. - // In other words, the notifiers should have dispatched 3 batches by now, but because the buffered updates have not - // been processed there is backpressure. - require.EqualValues(t, notifiers*batchSize, handler.sent.Load()+handler.err.Load()) + // We expect the notifier will have dispatched ONLY the initial batch of messages. + // In other words, the notifier should have dispatched 3 batches by now, but because the buffered updates have not + // been processed: there is backpressure. + require.EqualValues(t, batchSize, handler.sent.Load()+handler.err.Load()) // We expect that the store will have received NO updates. require.EqualValues(t, 0, storeInterceptor.sent.Load()+storeInterceptor.failed.Load()) // However, when we Stop() the manager the backpressure will be relieved and the buffered updates will ALL be flushed, - // since all the goroutines blocked on writing updates to the buffer will be unblocked and will complete. + // since all the goroutines that were blocked (on writing updates to the buffer) will be unblocked and will complete. require.NoError(t, mgr.Stop(ctx)) - require.EqualValues(t, notifiers*batchSize, storeInterceptor.sent.Load()+storeInterceptor.failed.Load()) + require.EqualValues(t, batchSize, storeInterceptor.sent.Load()+storeInterceptor.failed.Load()) } func TestRetries(t *testing.T) { @@ -394,9 +393,7 @@ func TestRetries(t *testing.T) { require.NoError(t, err) } - // Start two notifiers. - const notifiers = 2 - mgr.Run(ctx, notifiers) + mgr.Run(ctx) // then require.Eventually(t, func() bool { diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 12c5bbd890210..5773d3b817f89 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -467,7 +467,6 @@ type NotificationsConfig struct { StoreSyncBufferSize serpent.Int64 `json:"sync_buffer_size" typescript:",notnull"` // Queue. - WorkerCount serpent.Int64 `json:"worker_count"` LeasePeriod serpent.Duration `json:"lease_period"` LeaseCount serpent.Int64 `json:"lease_count"` FetchInterval serpent.Duration `json:"fetch_interval"` @@ -2196,18 +2195,6 @@ Write out the current server config as YAML to stdout.`, YAML: "store-sync-buffer-size", Hidden: true, // Hidden because most operators should not need to modify this. }, - { - Name: "Notifications: Worker Count", - Description: "How many workers should be processing messages in the queue; increase this count if notifications " + - "are not being processed fast enough.", - Flag: "notifications-worker-count", - Env: "CODER_NOTIFICATIONS_WORKER_COUNT", - Value: &c.Notifications.WorkerCount, - Default: "2", - Group: &deploymentGroupNotifications, - YAML: "worker-count", - Hidden: true, // Hidden because most operators should not need to modify this. - }, { Name: "Notifications: Lease Period", Description: "How long a notifier should lease a message. This is effectively how long a notification is 'owned' " + @@ -2230,7 +2217,7 @@ Write out the current server config as YAML to stdout.`, Flag: "notifications-lease-count", Env: "CODER_NOTIFICATIONS_LEASE_COUNT", Value: &c.Notifications.LeaseCount, - Default: "10", + Default: "20", Group: &deploymentGroupNotifications, YAML: "lease-count", Hidden: true, // Hidden because most operators should not need to modify this. diff --git a/docs/api/general.md b/docs/api/general.md index b72f8ca7af643..8bd968c6b18ed 100644 --- a/docs/api/general.md +++ b/docs/api/general.md @@ -285,8 +285,7 @@ curl -X GET http://coder-server:8080/api/v2/deployment/config \ "scheme": "string", "user": {} } - }, - "worker_count": 0 + } }, "oauth2": { "github": { diff --git a/docs/api/schemas.md b/docs/api/schemas.md index bc1a479f5d058..3f74997cee864 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1711,8 +1711,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "scheme": "string", "user": {} } - }, - "worker_count": 0 + } }, "oauth2": { "github": { @@ -2119,8 +2118,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "scheme": "string", "user": {} } - }, - "worker_count": 0 + } }, "oauth2": { "github": { @@ -3083,8 +3081,7 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o "scheme": "string", "user": {} } - }, - "worker_count": 0 + } } ``` @@ -3096,14 +3093,13 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o | `email` | [codersdk.NotificationsEmailConfig](#codersdknotificationsemailconfig) | false | | | | `fetch_interval` | integer | false | | | | `lease_count` | integer | false | | | -| `lease_period` | integer | false | | | +| `lease_period` | integer | false | | Queue. | | `max_send_attempts` | integer | false | | Retries. | | `method` | string | false | | Dispatch. | | `retry_interval` | integer | false | | | | `sync_buffer_size` | integer | false | | | | `sync_interval` | integer | false | | Store updates. | | `webhook` | [codersdk.NotificationsWebhookConfig](#codersdknotificationswebhookconfig) | false | | | -| `worker_count` | integer | false | | Queue. | ## codersdk.NotificationsEmailConfig diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 72a3dfd714f0f..72892955c6485 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -693,7 +693,6 @@ export interface NotificationsConfig { readonly retry_interval: number; readonly sync_interval: number; readonly sync_buffer_size: number; - readonly worker_count: number; readonly lease_period: number; readonly lease_count: number; readonly fetch_interval: number; From 4b7214d133f2aac4aa47dbd0e0ea4d7df5b2cd06 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 15:17:27 +0200 Subject: [PATCH 21/26] Fix docs Signed-off-by: Danny Kopping --- coderd/apidoc/docs.go | 34 ++++++++++++++++++++----- coderd/apidoc/swagger.json | 34 ++++++++++++++++++++----- codersdk/deployment.go | 52 +++++++++++++++++++++++++------------- docs/api/schemas.md | 32 +++++++++++------------ 4 files changed, 105 insertions(+), 47 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 82a7f60e02d16..374d38ddc6498 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9935,41 +9935,56 @@ const docTemplate = `{ "type": "object", "properties": { "dispatch_timeout": { + "description": "How long to wait while a notification is being sent before giving up.", "type": "integer" }, "email": { - "$ref": "#/definitions/codersdk.NotificationsEmailConfig" + "description": "SMTP settings.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsEmailConfig" + } + ] }, "fetch_interval": { + "description": "How often to query the database for queued notifications.", "type": "integer" }, "lease_count": { + "description": "How many notifications a notifier should lease per fetch interval.", "type": "integer" }, "lease_period": { - "description": "Queue.", + "description": "How long a notifier should lease a message. This is effectively how long a notification is 'owned'\nby a notifier, and once this period expires it will be available for lease by another notifier. Leasing\nis important in order for multiple running notifiers to not pick the same messages to deliver concurrently.\nThis lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification\nreleases the lease.", "type": "integer" }, "max_send_attempts": { - "description": "Retries.", + "description": "The upper limit of attempts to send a notification.", "type": "integer" }, "method": { - "description": "Dispatch.", + "description": "Which delivery method to use (available options: 'smtp', 'webhook').", "type": "string" }, "retry_interval": { + "description": "The minimum time between retries.", "type": "integer" }, "sync_buffer_size": { + "description": "The notifications system buffers message updates in memory to ease pressure on the database.\nThis option controls how many updates are kept in memory. The lower this value the\nlower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the\ndatabase. It is recommended to keep this option at its default value.", "type": "integer" }, "sync_interval": { - "description": "Store updates.", + "description": "The notifications system buffers message updates in memory to ease pressure on the database.\nThis option controls how often it synchronizes its state with the database. The shorter this value the\nlower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the\ndatabase. It is recommended to keep this option at its default value.", "type": "integer" }, "webhook": { - "$ref": "#/definitions/codersdk.NotificationsWebhookConfig" + "description": "Webhook settings.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsWebhookConfig" + } + ] } } }, @@ -9998,7 +10013,12 @@ const docTemplate = `{ "type": "object", "properties": { "endpoint": { - "$ref": "#/definitions/serpent.URL" + "description": "The URL to which the payload will be sent with an HTTP POST request.", + "allOf": [ + { + "$ref": "#/definitions/serpent.URL" + } + ] } } }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 503435822f05d..567e64cdcd2ef 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8904,41 +8904,56 @@ "type": "object", "properties": { "dispatch_timeout": { + "description": "How long to wait while a notification is being sent before giving up.", "type": "integer" }, "email": { - "$ref": "#/definitions/codersdk.NotificationsEmailConfig" + "description": "SMTP settings.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsEmailConfig" + } + ] }, "fetch_interval": { + "description": "How often to query the database for queued notifications.", "type": "integer" }, "lease_count": { + "description": "How many notifications a notifier should lease per fetch interval.", "type": "integer" }, "lease_period": { - "description": "Queue.", + "description": "How long a notifier should lease a message. This is effectively how long a notification is 'owned'\nby a notifier, and once this period expires it will be available for lease by another notifier. Leasing\nis important in order for multiple running notifiers to not pick the same messages to deliver concurrently.\nThis lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification\nreleases the lease.", "type": "integer" }, "max_send_attempts": { - "description": "Retries.", + "description": "The upper limit of attempts to send a notification.", "type": "integer" }, "method": { - "description": "Dispatch.", + "description": "Which delivery method to use (available options: 'smtp', 'webhook').", "type": "string" }, "retry_interval": { + "description": "The minimum time between retries.", "type": "integer" }, "sync_buffer_size": { + "description": "The notifications system buffers message updates in memory to ease pressure on the database.\nThis option controls how many updates are kept in memory. The lower this value the\nlower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the\ndatabase. It is recommended to keep this option at its default value.", "type": "integer" }, "sync_interval": { - "description": "Store updates.", + "description": "The notifications system buffers message updates in memory to ease pressure on the database.\nThis option controls how often it synchronizes its state with the database. The shorter this value the\nlower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the\ndatabase. It is recommended to keep this option at its default value.", "type": "integer" }, "webhook": { - "$ref": "#/definitions/codersdk.NotificationsWebhookConfig" + "description": "Webhook settings.", + "allOf": [ + { + "$ref": "#/definitions/codersdk.NotificationsWebhookConfig" + } + ] } } }, @@ -8967,7 +8982,12 @@ "type": "object", "properties": { "endpoint": { - "$ref": "#/definitions/serpent.URL" + "description": "The URL to which the payload will be sent with an HTTP POST request.", + "allOf": [ + { + "$ref": "#/definitions/serpent.URL" + } + ] } } }, diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 5773d3b817f89..56aeb894fb4b7 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -458,24 +458,41 @@ type HealthcheckConfig struct { } type NotificationsConfig struct { - // Retries. - MaxSendAttempts serpent.Int64 `json:"max_send_attempts" typescript:",notnull"` - RetryInterval serpent.Duration `json:"retry_interval" typescript:",notnull"` - - // Store updates. - StoreSyncInterval serpent.Duration `json:"sync_interval" typescript:",notnull"` - StoreSyncBufferSize serpent.Int64 `json:"sync_buffer_size" typescript:",notnull"` - - // Queue. - LeasePeriod serpent.Duration `json:"lease_period"` - LeaseCount serpent.Int64 `json:"lease_count"` + // The upper limit of attempts to send a notification. + MaxSendAttempts serpent.Int64 `json:"max_send_attempts" typescript:",notnull"` + // The minimum time between retries. + RetryInterval serpent.Duration `json:"retry_interval" typescript:",notnull"` + + // The notifications system buffers message updates in memory to ease pressure on the database. + // This option controls how often it synchronizes its state with the database. The shorter this value the + // lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the + // database. It is recommended to keep this option at its default value. + StoreSyncInterval serpent.Duration `json:"sync_interval" typescript:",notnull"` + // The notifications system buffers message updates in memory to ease pressure on the database. + // This option controls how many updates are kept in memory. The lower this value the + // lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the + // database. It is recommended to keep this option at its default value. + StoreSyncBufferSize serpent.Int64 `json:"sync_buffer_size" typescript:",notnull"` + + // How long a notifier should lease a message. This is effectively how long a notification is 'owned' + // by a notifier, and once this period expires it will be available for lease by another notifier. Leasing + // is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. + // This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification + // releases the lease. + LeasePeriod serpent.Duration `json:"lease_period"` + // How many notifications a notifier should lease per fetch interval. + LeaseCount serpent.Int64 `json:"lease_count"` + // How often to query the database for queued notifications. FetchInterval serpent.Duration `json:"fetch_interval"` - // Dispatch. - Method serpent.String `json:"method"` - DispatchTimeout serpent.Duration `json:"dispatch_timeout"` - SMTP NotificationsEmailConfig `json:"email" typescript:",notnull"` - Webhook NotificationsWebhookConfig `json:"webhook" typescript:",notnull"` + // Which delivery method to use (available options: 'smtp', 'webhook'). + Method serpent.String `json:"method"` + // How long to wait while a notification is being sent before giving up. + DispatchTimeout serpent.Duration `json:"dispatch_timeout"` + // SMTP settings. + SMTP NotificationsEmailConfig `json:"email" typescript:",notnull"` + // Webhook settings. + Webhook NotificationsWebhookConfig `json:"webhook" typescript:",notnull"` } type NotificationsEmailConfig struct { @@ -500,12 +517,13 @@ type NotificationsEmailConfig struct { // // Identity used for PLAIN auth. // Identity serpent.String `json:"identity" typescript:",notnull"` // } `json:"auth" typescript:",notnull"` - //// Additional headers to use in the SMTP request. + // // Additional headers to use in the SMTP request. // Headers map[string]string `json:"headers" typescript:",notnull"` // TODO: TLS } type NotificationsWebhookConfig struct { + // The URL to which the payload will be sent with an HTTP POST request. Endpoint serpent.URL `json:"endpoint" typescript:",notnull"` } diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 3f74997cee864..ceffc1ef9e9e0 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -3087,19 +3087,19 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ------------------- | -------------------------------------------------------------------------- | -------- | ------------ | -------------- | -| `dispatch_timeout` | integer | false | | | -| `email` | [codersdk.NotificationsEmailConfig](#codersdknotificationsemailconfig) | false | | | -| `fetch_interval` | integer | false | | | -| `lease_count` | integer | false | | | -| `lease_period` | integer | false | | Queue. | -| `max_send_attempts` | integer | false | | Retries. | -| `method` | string | false | | Dispatch. | -| `retry_interval` | integer | false | | | -| `sync_buffer_size` | integer | false | | | -| `sync_interval` | integer | false | | Store updates. | -| `webhook` | [codersdk.NotificationsWebhookConfig](#codersdknotificationswebhookconfig) | false | | | +| Name | Type | Required | Restrictions | Description | +| ------------------- | -------------------------------------------------------------------------- | -------- | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `dispatch_timeout` | integer | false | | How long to wait while a notification is being sent before giving up. | +| `email` | [codersdk.NotificationsEmailConfig](#codersdknotificationsemailconfig) | false | | Email settings. | +| `fetch_interval` | integer | false | | How often to query the database for queued notifications. | +| `lease_count` | integer | false | | How many notifications a notifier should lease per fetch interval. | +| `lease_period` | integer | false | | How long a notifier should lease a message. This is effectively how long a notification is 'owned' by a notifier, and once this period expires it will be available for lease by another notifier. Leasing is important in order for multiple running notifiers to not pick the same messages to deliver concurrently. This lease period will only expire if a notifier shuts down ungracefully; a dispatch of the notification releases the lease. | +| `max_send_attempts` | integer | false | | The upper limit of attempts to send a notification. | +| `method` | string | false | | Which delivery method to use (available options: 'smtp', 'webhook'). | +| `retry_interval` | integer | false | | The minimum time between retries. | +| `sync_buffer_size` | integer | false | | The notifications system buffers message updates in memory to ease pressure on the database. This option controls how many updates are kept in memory. The lower this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. | +| `sync_interval` | integer | false | | The notifications system buffers message updates in memory to ease pressure on the database. This option controls how often it synchronizes its state with the database. The shorter this value the lower the change of state inconsistency in a non-graceful shutdown - but it also increases load on the database. It is recommended to keep this option at its default value. | +| `webhook` | [codersdk.NotificationsWebhookConfig](#codersdknotificationswebhookconfig) | false | | Webhook settings. | ## codersdk.NotificationsEmailConfig @@ -3144,9 +3144,9 @@ CreateWorkspaceRequest provides options for creating a new workspace. Only one o ### Properties -| Name | Type | Required | Restrictions | Description | -| ---------- | -------------------------- | -------- | ------------ | ----------- | -| `endpoint` | [serpent.URL](#serpenturl) | false | | | +| Name | Type | Required | Restrictions | Description | +| ---------- | -------------------------- | -------- | ------------ | -------------------------------------------------------------------- | +| `endpoint` | [serpent.URL](#serpenturl) | false | | The URL to which the payload will be sent with an HTTP POST request. | ## codersdk.OAuth2AppEndpoints From 6679ef1eb200f58d85ac2a08b4d971b248af4e41 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Mon, 1 Jul 2024 15:31:42 +0200 Subject: [PATCH 22/26] Fix lint error Signed-off-by: Danny Kopping --- coderd/notifications/manager_test.go | 2 ++ 1 file changed, 2 insertions(+) diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index bd7a0c7d421dc..3ddd99164ad11 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -138,6 +138,8 @@ func TestBuildPayload(t *testing.T) { } func TestStopBeforeRun(t *testing.T) { + t.Parallel() + ctx := context.Background() logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) mgr, err := notifications.NewManager(defaultNotificationsConfig(database.NotificationMethodSmtp), dbmem.New(), logger.Named("notifications-manager")) From 0f292932cc5b04ee96addab022e0ac726a7805eb Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Wed, 3 Jul 2024 17:45:38 +0200 Subject: [PATCH 23/26] Review feedback Signed-off-by: Danny Kopping --- coderd/database/dbauthz/dbauthz.go | 8 + coderd/database/dbmem/dbmem.go | 26 +- coderd/database/dbmetrics/dbmetrics.go | 7 + coderd/database/dbmock/dbmock.go | 15 + .../migrations/000221_notifications.up.sql | 2 +- coderd/database/querier.go | 1 + coderd/database/queries.sql.go | 47 +++ coderd/database/queries/notifications.sql | 3 + coderd/notifications/dispatch/smtp.go | 5 - coderd/notifications/dispatch/webhook.go | 6 - coderd/notifications/manager.go | 13 + coderd/notifications/manager_test.go | 44 +-- coderd/notifications/notifications_test.go | 327 +++++++++++++----- coderd/notifications/notifier.go | 22 +- coderd/notifications/spec.go | 3 +- coderd/notifications/utils_test.go | 23 +- .../provisionerdserver/provisionerdserver.go | 17 +- .../provisionerdserver_test.go | 18 +- 18 files changed, 420 insertions(+), 167 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 098922527c81f..67dadd5d74e19 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -17,6 +17,7 @@ import ( "github.com/open-policy-agent/opa/topdown" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/coderd/rbac/rolestore" @@ -1471,6 +1472,13 @@ func (q *querier) GetLogoURL(ctx context.Context) (string, error) { return q.db.GetLogoURL(ctx) } +func (q *querier) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) { + if err := q.authorizeContext(ctx, policy.ActionRead, rbac.ResourceSystem); err != nil { + return nil, err + } + return q.db.GetNotificationMessagesByStatus(ctx, arg) +} + 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 diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index a5010395a1098..adabdc9e04ee7 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -944,10 +944,10 @@ func (q *FakeQuerier) AcquireNotificationMessages(_ context.Context, arg databas } // Mimic mutation in database query. - nm.UpdatedAt = sql.NullTime{Time: time.Now(), Valid: true} + nm.UpdatedAt = sql.NullTime{Time: dbtime.Now(), Valid: true} nm.Status = database.NotificationMessageStatusLeased nm.StatusReason = sql.NullString{String: fmt.Sprintf("Enqueued by notifier %d", arg.NotifierID), Valid: true} - nm.LeasedUntil = sql.NullTime{Time: time.Now().Add(time.Second * time.Duration(arg.LeaseSeconds)), Valid: true} + nm.LeasedUntil = sql.NullTime{Time: dbtime.Now().Add(time.Second * time.Duration(arg.LeaseSeconds)), Valid: true} out = append(out, database.AcquireNotificationMessagesRow{ ID: nm.ID, @@ -1836,7 +1836,7 @@ func (q *FakeQuerier) EnqueueNotificationMessage(_ context.Context, arg database Targets: arg.Targets, CreatedBy: arg.CreatedBy, // Default fields. - CreatedAt: time.Now(), + CreatedAt: dbtime.Now(), Status: database.NotificationMessageStatusPending, } @@ -2740,6 +2740,26 @@ func (q *FakeQuerier) GetLogoURL(_ context.Context) (string, error) { return q.logoURL, nil } +func (q *FakeQuerier) GetNotificationMessagesByStatus(_ context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) { + err := validateDatabaseType(arg) + if err != nil { + return nil, err + } + + var out []database.NotificationMessage + for _, m := range q.notificationMessages { + if len(out) > int(arg.Limit) { + return out, nil + } + + if m.Status == arg.Status { + out = append(out, m) + } + } + + return out, nil +} + func (q *FakeQuerier) GetOAuth2ProviderAppByID(_ context.Context, id uuid.UUID) (database.OAuth2ProviderApp, error) { q.mutex.Lock() defer q.mutex.Unlock() diff --git a/coderd/database/dbmetrics/dbmetrics.go b/coderd/database/dbmetrics/dbmetrics.go index fbaf7d4fc0b4e..0a7ecd4fb5f10 100644 --- a/coderd/database/dbmetrics/dbmetrics.go +++ b/coderd/database/dbmetrics/dbmetrics.go @@ -732,6 +732,13 @@ func (m metricsStore) GetLogoURL(ctx context.Context) (string, error) { return url, err } +func (m metricsStore) GetNotificationMessagesByStatus(ctx context.Context, arg database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) { + start := time.Now() + r0, r1 := m.s.GetNotificationMessagesByStatus(ctx, arg) + m.queryLatencies.WithLabelValues("GetNotificationMessagesByStatus").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) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 7f00a57587216..982a6472ec16c 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -1452,6 +1452,21 @@ func (mr *MockStoreMockRecorder) GetLogoURL(arg0 any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetLogoURL", reflect.TypeOf((*MockStore)(nil).GetLogoURL), arg0) } +// GetNotificationMessagesByStatus mocks base method. +func (m *MockStore) GetNotificationMessagesByStatus(arg0 context.Context, arg1 database.GetNotificationMessagesByStatusParams) ([]database.NotificationMessage, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetNotificationMessagesByStatus", arg0, arg1) + ret0, _ := ret[0].([]database.NotificationMessage) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// GetNotificationMessagesByStatus indicates an expected call of GetNotificationMessagesByStatus. +func (mr *MockStoreMockRecorder) GetNotificationMessagesByStatus(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetNotificationMessagesByStatus", reflect.TypeOf((*MockStore)(nil).GetNotificationMessagesByStatus), arg0, arg1) +} + // GetOAuth2ProviderAppByID mocks base method. func (m *MockStore) GetOAuth2ProviderAppByID(arg0 context.Context, arg1 uuid.UUID) (database.OAuth2ProviderApp, error) { m.ctrl.T.Helper() diff --git a/coderd/database/migrations/000221_notifications.up.sql b/coderd/database/migrations/000221_notifications.up.sql index 567ed87d80764..3e1ed645fe40d 100644 --- a/coderd/database/migrations/000221_notifications.up.sql +++ b/coderd/database/migrations/000221_notifications.up.sql @@ -52,7 +52,7 @@ CREATE INDEX idx_notification_messages_status ON notification_messages (status); -- TODO: autogenerate constants which reference the UUIDs INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions) VALUES ('f517da0b-cdc9-410f-ab89-a86107c420ed', 'Workspace Deleted', E'Workspace "{{.Labels.name}}" deleted', - E'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}**".', + E'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}{{ if .Labels.initiatedBy }} ({{ .Labels.initiatedBy }}){{end}}**".', 'Workspace Events', '[ { "label": "View workspaces", diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 179a5e06039ff..75ade1dc12e5e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -160,6 +160,7 @@ type sqlcQuerier interface { GetLicenseByID(ctx context.Context, id int32) (License, error) GetLicenses(ctx context.Context) ([]License, error) GetLogoURL(ctx context.Context) (string, error) + GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, 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) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ff7b7f6f955bd..beab3c79ec5c7 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3579,6 +3579,53 @@ func (q *sqlQuerier) FetchNewMessageMetadata(ctx context.Context, arg FetchNewMe return i, err } +const getNotificationMessagesByStatus = `-- name: GetNotificationMessagesByStatus :many +SELECT id, notification_template_id, user_id, method, status, status_reason, created_by, payload, attempt_count, targets, created_at, updated_at, leased_until, next_retry_after FROM notification_messages WHERE status = $1 LIMIT $2::int +` + +type GetNotificationMessagesByStatusParams struct { + Status NotificationMessageStatus `db:"status" json:"status"` + Limit int32 `db:"limit" json:"limit"` +} + +func (q *sqlQuerier) GetNotificationMessagesByStatus(ctx context.Context, arg GetNotificationMessagesByStatusParams) ([]NotificationMessage, error) { + rows, err := q.db.QueryContext(ctx, getNotificationMessagesByStatus, arg.Status, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []NotificationMessage + for rows.Next() { + var i NotificationMessage + if err := rows.Scan( + &i.ID, + &i.NotificationTemplateID, + &i.UserID, + &i.Method, + &i.Status, + &i.StatusReason, + &i.CreatedBy, + &i.Payload, + &i.AttemptCount, + pq.Array(&i.Targets), + &i.CreatedAt, + &i.UpdatedAt, + &i.LeasedUntil, + &i.NextRetryAfter, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const deleteOAuth2ProviderAppByID = `-- name: DeleteOAuth2ProviderAppByID :exec DELETE FROM oauth2_provider_apps WHERE id = $1 ` diff --git a/coderd/database/queries/notifications.sql b/coderd/database/queries/notifications.sql index 8cc31e0661927..2949c8f86e27b 100644 --- a/coderd/database/queries/notifications.sql +++ b/coderd/database/queries/notifications.sql @@ -125,3 +125,6 @@ WHERE id IN FROM notification_messages AS nested WHERE nested.updated_at < NOW() - INTERVAL '7 days'); +-- name: GetNotificationMessagesByStatus :many +SELECT * FROM notification_messages WHERE status = @status LIMIT sqlc.arg('limit')::int; + diff --git a/coderd/notifications/dispatch/smtp.go b/coderd/notifications/dispatch/smtp.go index 0aeb678115101..9473a1666974d 100644 --- a/coderd/notifications/dispatch/smtp.go +++ b/coderd/notifications/dispatch/smtp.go @@ -20,7 +20,6 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/notifications/render" "github.com/coder/coder/v2/coderd/notifications/types" markdown "github.com/coder/coder/v2/coderd/render" @@ -53,10 +52,6 @@ func NewSMTPHandler(cfg codersdk.NotificationsEmailConfig, log slog.Logger) *SMT return &SMTPHandler{cfg: cfg, log: log} } -func (*SMTPHandler) NotificationMethod() database.NotificationMethod { - return database.NotificationMethodSmtp -} - func (s *SMTPHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string) (DeliveryFunc, error) { // First render the subject & body into their own discrete strings. subject, err := markdown.PlaintextFromMarkdown(titleTmpl) diff --git a/coderd/notifications/dispatch/webhook.go b/coderd/notifications/dispatch/webhook.go index 79df1aef2c5c7..5c4d978919a16 100644 --- a/coderd/notifications/dispatch/webhook.go +++ b/coderd/notifications/dispatch/webhook.go @@ -13,7 +13,6 @@ import ( "cdr.dev/slog" - "github.com/coder/coder/v2/coderd/database" "github.com/coder/coder/v2/coderd/notifications/types" markdown "github.com/coder/coder/v2/coderd/render" "github.com/coder/coder/v2/codersdk" @@ -40,11 +39,6 @@ func NewWebhookHandler(cfg codersdk.NotificationsWebhookConfig, log slog.Logger) return &WebhookHandler{cfg: cfg, log: log, cl: &http.Client{}} } -func (*WebhookHandler) NotificationMethod() database.NotificationMethod { - // TODO: don't use database types - return database.NotificationMethodWebhook -} - func (w *WebhookHandler) Dispatcher(payload types.MessagePayload, titleTmpl, bodyTmpl string) (DeliveryFunc, error) { if w.cfg.Endpoint.String() == "" { return nil, xerrors.New("webhook endpoint not defined") diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 1f7da234f71e6..d104fb783e966 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -16,6 +16,10 @@ import ( "github.com/coder/coder/v2/codersdk" ) +var ( + ErrInvalidDispatchTimeout = xerrors.New("dispatch timeout must be less than lease period") +) + // Manager manages all notifications being enqueued and dispatched. // // Manager maintains a notifier: this consumes the queue of notification messages in the store. @@ -53,6 +57,13 @@ type Manager struct { // helpers is a map of template helpers which are used to customize notification messages to use global settings like // access URL etc. func NewManager(cfg codersdk.NotificationsConfig, store Store, log slog.Logger) (*Manager, error) { + // If dispatch timeout exceeds lease period, it is possible that messages can be delivered in duplicate because the + // lease can expire before the notifier gives up on the dispatch, which results in the message becoming eligible for + // being re-acquired. + if cfg.DispatchTimeout.Value() >= cfg.LeasePeriod.Value() { + return nil, ErrInvalidDispatchTimeout + } + return &Manager{ log: log, cfg: cfg, @@ -82,6 +93,8 @@ func (m *Manager) WithHandlers(reg map[database.NotificationMethod]Handler) { // Manager requires system-level permissions to interact with the store. // Run is only intended to be run once. func (m *Manager) Run(ctx context.Context) { + m.log.Info(ctx, "started") + m.runOnce.Do(func() { // Closes when Stop() is called or context is canceled. go func() { diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index 3ddd99164ad11..d5b4d0df615b6 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -5,7 +5,6 @@ import ( "encoding/json" "sync/atomic" "testing" - "time" "github.com/google/uuid" "github.com/stretchr/testify/assert" @@ -15,8 +14,8 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbtestutil" "github.com/coder/coder/v2/coderd/notifications" @@ -32,7 +31,7 @@ func TestBufferedUpdates(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("This test requires postgres") } - ctx, logger, db, ps := setup(t) + ctx, logger, db := setup(t) interceptor := &bulkUpdateInterceptor{Store: db} santa := &santaHandler{} @@ -45,19 +44,15 @@ func TestBufferedUpdates(t *testing.T) { enq, err := notifications.NewStoreEnqueuer(cfg, interceptor, defaultHelpers(), logger.Named("notifications-enqueuer")) require.NoError(t, err) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) - user := coderdtest.CreateFirstUser(t, client) + user := dbgen.User(t, db, database.User{}) // given - if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, ""); true { - require.NoError(t, err) - } - if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, ""); true { - require.NoError(t, err) - } - if _, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "false"}, ""); true { - require.NoError(t, err) - } + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, "") + require.NoError(t, err) + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, "") + require.NoError(t, err) + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "false"}, "") + require.NoError(t, err) // when mgr.Run(ctx) @@ -94,7 +89,6 @@ func TestBuildPayload(t *testing.T) { "my_url": func() string { return url }, } - ctx := context.Background() db := dbmem.New() interceptor := newEnqueueInterceptor(db, // Inject custom message metadata to influence the payload construction. @@ -107,7 +101,7 @@ func TestBuildPayload(t *testing.T) { }, } out, err := json.Marshal(actions) - require.NoError(t, err) + assert.NoError(t, err) return database.FetchNewMessageMetadataRow{ NotificationName: "My Notification", @@ -122,19 +116,17 @@ func TestBuildPayload(t *testing.T) { enq, err := notifications.NewStoreEnqueuer(defaultNotificationsConfig(database.NotificationMethodSmtp), interceptor, helpers, logger.Named("notifications-enqueuer")) require.NoError(t, err) + ctx := testutil.Context(t, testutil.WaitShort) + // when _, err = enq.Enqueue(ctx, uuid.New(), notifications.TemplateWorkspaceDeleted, nil, "test") require.NoError(t, err) // then - select { - case payload := <-interceptor.payload: - require.Len(t, payload.Actions, 1) - require.Equal(t, label, payload.Actions[0].Label) - require.Equal(t, url, payload.Actions[0].URL) - case <-time.After(testutil.WaitShort): - t.Fatalf("timed out") - } + payload := testutil.RequireRecvCtx(ctx, t, interceptor.payload) + require.Len(t, payload.Actions, 1) + require.Equal(t, label, payload.Actions[0].Label) + require.Equal(t, url, payload.Actions[0].URL) } func TestStopBeforeRun(t *testing.T) { @@ -184,10 +176,6 @@ type santaHandler struct { nice atomic.Int32 } -func (*santaHandler) NotificationMethod() database.NotificationMethod { - return database.NotificationMethodSmtp -} - func (s *santaHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) { return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { if payload.Labels["nice"] != "true" { diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 688324270f7c9..6c2cf430fe460 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -7,6 +7,8 @@ import ( "net/http" "net/http/httptest" "net/url" + "sort" + "sync" "sync/atomic" "testing" "time" @@ -17,14 +19,18 @@ import ( "github.com/stretchr/testify/require" "go.uber.org/goleak" + "cdr.dev/slog" + "cdr.dev/slog/sloggers/slogtest" "github.com/coder/serpent" - "github.com/coder/coder/v2/coderd/coderdtest" "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbmem" "github.com/coder/coder/v2/coderd/database/dbtestutil" "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/coderd/util/syncmap" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" ) @@ -43,7 +49,7 @@ func TestBasicNotificationRoundtrip(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("This test requires postgres") } - ctx, logger, db, ps := setup(t) + ctx, logger, db := setup(t) method := database.NotificationMethodSmtp // given @@ -54,25 +60,32 @@ func TestBasicNotificationRoundtrip(t *testing.T) { require.NoError(t, err) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) t.Cleanup(func() { - require.NoError(t, mgr.Stop(ctx)) + assert.NoError(t, mgr.Stop(ctx)) }) enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) - user := coderdtest.CreateFirstUser(t, client) + user := createSampleUser(t, db) // when - sid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "success"}, "test") + sid, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "success"}, "test") require.NoError(t, err) - fid, err := enq.Enqueue(ctx, user.UserID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "failure"}, "test") + fid, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "failure"}, "test") require.NoError(t, err) mgr.Run(ctx) // then - require.Eventually(t, func() bool { return handler.succeeded == sid.String() }, testutil.WaitLong, testutil.IntervalMedium) - require.Eventually(t, func() bool { return handler.failed == fid.String() }, testutil.WaitLong, testutil.IntervalMedium) + require.Eventually(t, func() bool { + handler.mu.RLock() + defer handler.mu.RUnlock() + return handler.succeeded == sid.String() + }, testutil.WaitLong, testutil.IntervalMedium) + require.Eventually(t, func() bool { + handler.mu.RLock() + defer handler.mu.RUnlock() + return handler.failed == fid.String() + }, testutil.WaitLong, testutil.IntervalMedium) } func TestSMTPDispatch(t *testing.T) { @@ -82,16 +95,16 @@ func TestSMTPDispatch(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("This test requires postgres") } - ctx, logger, db, ps := setup(t) + ctx, logger, db := setup(t) // start mock SMTP server mockSMTPSrv := smtpmock.New(smtpmock.ConfigurationAttr{ - LogToStdout: true, + LogToStdout: false, LogServerActivity: true, }) require.NoError(t, mockSMTPSrv.Start()) t.Cleanup(func() { - require.NoError(t, mockSMTPSrv.Stop()) + assert.NoError(t, mockSMTPSrv.Stop()) }) // given @@ -108,17 +121,12 @@ func TestSMTPDispatch(t *testing.T) { require.NoError(t, err) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) t.Cleanup(func() { - require.NoError(t, mgr.Stop(ctx)) + assert.NoError(t, mgr.Stop(ctx)) }) enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) - first := coderdtest.CreateFirstUser(t, client) - _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { - r.Email = "bob@coder.com" - r.Username = "bob" - }) + user := createSampleUser(t, db) // when msgID, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{}, "test") @@ -147,33 +155,20 @@ func TestWebhookDispatch(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("This test requires postgres") } - ctx, logger, db, ps := setup(t) - - var ( - msgID *uuid.UUID - input map[string]string - ) + ctx, logger, db := setup(t) - sent := make(chan bool, 1) + sent := make(chan dispatch.WebhookPayload, 1) // Mock server to simulate webhook endpoint. server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload dispatch.WebhookPayload err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) - - require.Equal(t, "application/json", r.Header.Get("Content-Type")) - require.EqualValues(t, "1.0", payload.Version) - require.Equal(t, *msgID, payload.MsgID) - require.Equal(t, payload.Payload.Labels, input) - require.Equal(t, payload.Payload.UserEmail, "bob@coder.com") - // UserName is coalesced from `name` and `username`; in this case `name` wins. - require.Equal(t, payload.Payload.UserName, "Robert McBobbington") - require.Equal(t, payload.Payload.NotificationName, "Workspace Deleted") + assert.NoError(t, err) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) w.WriteHeader(http.StatusOK) _, err = w.Write([]byte("noted.")) - require.NoError(t, err) - sent <- true + assert.NoError(t, err) + sent <- payload })) defer server.Close() @@ -188,31 +183,36 @@ func TestWebhookDispatch(t *testing.T) { mgr, err := notifications.NewManager(cfg, db, logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { - require.NoError(t, mgr.Stop(ctx)) + assert.NoError(t, mgr.Stop(ctx)) }) enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) - first := coderdtest.CreateFirstUser(t, client) - _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { - r.Email = "bob@coder.com" - r.Username = "bob" - r.Name = "Robert McBobbington" + user := dbgen.User(t, db, database.User{ + Email: "bob@coder.com", + Username: "bob", + Name: "Robert McBobbington", }) // when - input = map[string]string{ + input := map[string]string{ "a": "b", "c": "d", } - msgID, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, input, "test") + msgID, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, input, "test") require.NoError(t, err) mgr.Run(ctx) // then - require.Eventually(t, func() bool { return <-sent }, testutil.WaitShort, testutil.IntervalFast) + payload := testutil.RequireRecvCtx(testutil.Context(t, testutil.WaitShort), t, sent) + require.EqualValues(t, "1.0", payload.Version) + require.Equal(t, *msgID, payload.MsgID) + require.Equal(t, payload.Payload.Labels, input) + require.Equal(t, payload.Payload.UserEmail, "bob@coder.com") + // UserName is coalesced from `name` and `username`; in this case `name` wins. + require.Equal(t, payload.Payload.UserName, "Robert McBobbington") + require.Equal(t, payload.Payload.NotificationName, "Workspace Deleted") } // TestBackpressure validates that delays in processing the buffered updates will result in slowed dequeue rates. @@ -225,18 +225,18 @@ func TestBackpressure(t *testing.T) { t.Skip("This test requires postgres") } - ctx, logger, db, ps := setup(t) + ctx, logger, db := setup(t) // Mock server to simulate webhook endpoint. var received atomic.Int32 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload dispatch.WebhookPayload err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) + assert.NoError(t, err) w.WriteHeader(http.StatusOK) _, err = w.Write([]byte("noted.")) - require.NoError(t, err) + assert.NoError(t, err) received.Add(1) })) @@ -275,12 +275,7 @@ func TestBackpressure(t *testing.T) { enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) - first := coderdtest.CreateFirstUser(t, client) - _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { - r.Email = "bob@coder.com" - r.Username = "bob" - }) + user := createSampleUser(t, db) // when const totalMessages = 30 @@ -318,34 +313,33 @@ func TestRetries(t *testing.T) { t.Skip("This test requires postgres") } - ctx, logger, db, ps := setup(t) - const maxAttempts = 3 + ctx, logger, db := setup(t) + // given + + receivedMap := syncmap.New[uuid.UUID, int]() // Mock server to simulate webhook endpoint. - receivedMap := make(map[uuid.UUID]*atomic.Int32) server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var payload dispatch.WebhookPayload err := json.NewDecoder(r.Body).Decode(&payload) - require.NoError(t, err) + assert.NoError(t, err) - if _, ok := receivedMap[payload.MsgID]; !ok { - receivedMap[payload.MsgID] = &atomic.Int32{} - } - - counter := receivedMap[payload.MsgID] + count, _ := receivedMap.LoadOrStore(payload.MsgID, 0) + count++ + receivedMap.Store(payload.MsgID, count) // Let the request succeed if this is its last attempt. - if counter.Add(1) == maxAttempts { + if count == maxAttempts { w.WriteHeader(http.StatusOK) _, err = w.Write([]byte("noted.")) - require.NoError(t, err) + assert.NoError(t, err) return } w.WriteHeader(http.StatusInternalServerError) _, err = w.Write([]byte("retry again later...")) - require.NoError(t, err) + assert.NoError(t, err) })) defer server.Close() @@ -370,25 +364,20 @@ func TestRetries(t *testing.T) { // Intercept calls to submit the buffered updates to the store. storeInterceptor := &bulkUpdateInterceptor{Store: db} - // given mgr, err := notifications.NewManager(cfg, storeInterceptor, logger.Named("manager")) require.NoError(t, err) t.Cleanup(func() { - require.NoError(t, mgr.Stop(ctx)) + assert.NoError(t, mgr.Stop(ctx)) }) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) require.NoError(t, err) - client := coderdtest.New(t, &coderdtest.Options{Database: db, Pubsub: ps}) - first := coderdtest.CreateFirstUser(t, client) - _, user := coderdtest.CreateAnotherUserMutators(t, client, first.OrganizationID, nil, func(r *codersdk.CreateUserRequest) { - r.Email = "bob@coder.com" - r.Username = "bob" - }) + user := createSampleUser(t, db) // when - for i := 0; i < 1; i++ { + const msgCount = 5 + for i := 0; i < msgCount; i++ { _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"i": fmt.Sprintf("%d", i)}, "test") require.NoError(t, err) } @@ -397,22 +386,149 @@ func TestRetries(t *testing.T) { // then require.Eventually(t, func() bool { - return storeInterceptor.failed.Load() == maxAttempts-1 && - storeInterceptor.sent.Load() == 1 + // We expect all messages to fail all attempts but the final; + return storeInterceptor.failed.Load() == msgCount*(maxAttempts-1) && + // ...and succeed on the final attempt. + storeInterceptor.sent.Load() == msgCount }, testutil.WaitLong, testutil.IntervalFast) } +// TestExpiredLeaseIsRequeued validates that notification messages which are left in "leased" status will be requeued once their lease expires. +// "leased" is the status which messages are set to when they are acquired for processing, and this should not be a terminal +// state unless the Manager shuts down ungracefully; the Manager is responsible for updating these messages' statuses once +// they have been processed. +func TestExpiredLeaseIsRequeued(t *testing.T) { + t.Parallel() + + // setup + if !dbtestutil.WillUsePostgres() { + t.Skip("This test requires postgres") + } + + ctx, logger, db := setup(t) + + // given + + const ( + leasePeriod = time.Second + msgCount = 5 + method = database.NotificationMethodSmtp + ) + + cfg := defaultNotificationsConfig(method) + // Set low lease period to speed up tests. + cfg.LeasePeriod = serpent.Duration(leasePeriod) + cfg.DispatchTimeout = serpent.Duration(leasePeriod - time.Millisecond) + + noopInterceptor := newNoopBulkUpdater(db) + + mgrCtx, cancelManagerCtx := context.WithCancel(context.Background()) + t.Cleanup(cancelManagerCtx) + + mgr, err := notifications.NewManager(cfg, noopInterceptor, logger.Named("manager")) + require.NoError(t, err) + enq, err := notifications.NewStoreEnqueuer(cfg, db, defaultHelpers(), logger.Named("enqueuer")) + require.NoError(t, err) + + user := createSampleUser(t, db) + + // when + var msgs []string + for i := 0; i < msgCount; i++ { + id, err := enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"type": "success"}, "test") + require.NoError(t, err) + msgs = append(msgs, id.String()) + } + + mgr.Run(mgrCtx) + + // Wait for the messages to be acquired + <-noopInterceptor.acquiredChan + // Then cancel the context, forcing the notification manager to shutdown ungracefully (simulating a crash); leaving messages in "leased" status. + cancelManagerCtx() + + // Fetch any messages currently in "leased" status, and verify that they're exactly the ones we enqueued. + leased, err := db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ + Status: database.NotificationMessageStatusLeased, + Limit: msgCount, + }) + require.NoError(t, err) + + var leasedIDs []string + for _, msg := range leased { + leasedIDs = append(leasedIDs, msg.ID.String()) + } + + sort.Strings(msgs) + sort.Strings(leasedIDs) + require.EqualValues(t, msgs, leasedIDs) + + // Wait out the lease period; all messages should be eligible to be re-acquired. + time.Sleep(leasePeriod + time.Millisecond) + + // Start a new notification manager. + // Intercept calls to submit the buffered updates to the store. + storeInterceptor := &bulkUpdateInterceptor{Store: db} + handler := newDispatchInterceptor(&fakeHandler{}) + mgr, err = notifications.NewManager(cfg, storeInterceptor, logger.Named("manager")) + require.NoError(t, err) + mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{method: handler}) + + // Use regular context now. + t.Cleanup(func() { + assert.NoError(t, mgr.Stop(ctx)) + }) + mgr.Run(ctx) + + // Wait until all messages are sent & updates flushed to the database. + require.Eventually(t, func() bool { + return handler.sent.Load() == msgCount && + storeInterceptor.sent.Load() == msgCount + }, testutil.WaitLong, testutil.IntervalFast) + + // Validate that no more messages are in "leased" status. + leased, err = db.GetNotificationMessagesByStatus(ctx, database.GetNotificationMessagesByStatusParams{ + Status: database.NotificationMessageStatusLeased, + Limit: msgCount, + }) + require.NoError(t, err) + require.Len(t, leased, 0) +} + +// TestInvalidConfig validates that misconfigurations lead to errors. +func TestInvalidConfig(t *testing.T) { + t.Parallel() + + db := dbmem.New() + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true, IgnoredErrorIs: []error{}}).Leveled(slog.LevelDebug) + + // given + + const ( + leasePeriod = time.Second + method = database.NotificationMethodSmtp + ) + + cfg := defaultNotificationsConfig(method) + cfg.LeasePeriod = serpent.Duration(leasePeriod) + cfg.DispatchTimeout = serpent.Duration(leasePeriod) + + _, err := notifications.NewManager(cfg, db, logger.Named("manager")) + require.ErrorIs(t, err, notifications.ErrInvalidDispatchTimeout) +} + type fakeHandler struct { + mu sync.RWMutex + succeeded string failed string } -func (*fakeHandler) NotificationMethod() database.NotificationMethod { - return database.NotificationMethodSmtp -} - func (f *fakeHandler) Dispatcher(payload types.MessagePayload, _, _ string) (dispatch.DeliveryFunc, error) { return func(ctx context.Context, msgID uuid.UUID) (retryable bool, err error) { + f.mu.Lock() + defer f.mu.Unlock() + if payload.Labels["type"] == "success" { f.succeeded = msgID.String() } else { @@ -433,11 +549,9 @@ type dispatchInterceptor struct { } func newDispatchInterceptor(h notifications.Handler) *dispatchInterceptor { - return &dispatchInterceptor{handler: h} -} - -func (i *dispatchInterceptor) NotificationMethod() database.NotificationMethod { - return i.handler.NotificationMethod() + return &dispatchInterceptor{ + handler: h, + } } func (i *dispatchInterceptor) Dispatcher(payload types.MessagePayload, title, body string) (dispatch.DeliveryFunc, error) { @@ -465,3 +579,38 @@ func (i *dispatchInterceptor) Dispatcher(payload types.MessagePayload, title, bo return retryable, err }, nil } + +// noopBulkUpdater pretends to perform bulk updates, but does not; leading to messages being stuck in "leased" state. +type noopBulkUpdater struct { + *acquireSignalingInterceptor +} + +func newNoopBulkUpdater(db notifications.Store) *noopBulkUpdater { + return &noopBulkUpdater{newAcquireSignalingInterceptor(db)} +} + +func (*noopBulkUpdater) BulkMarkNotificationMessagesSent(_ context.Context, arg database.BulkMarkNotificationMessagesSentParams) (int64, error) { + return int64(len(arg.IDs)), nil +} + +func (*noopBulkUpdater) BulkMarkNotificationMessagesFailed(_ context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) { + return int64(len(arg.IDs)), nil +} + +type acquireSignalingInterceptor struct { + notifications.Store + acquiredChan chan struct{} +} + +func newAcquireSignalingInterceptor(db notifications.Store) *acquireSignalingInterceptor { + return &acquireSignalingInterceptor{ + Store: db, + acquiredChan: make(chan struct{}, 1), + } +} + +func (n *acquireSignalingInterceptor) AcquireNotificationMessages(ctx context.Context, params database.AcquireNotificationMessagesParams) ([]database.AcquireNotificationMessagesRow, error) { + messages, err := n.Store.AcquireNotificationMessages(ctx, params) + n.acquiredChan <- struct{}{} + return messages, err +} diff --git a/coderd/notifications/notifier.go b/coderd/notifications/notifier.go index e2edf8b98b5a8..b214f8a77a070 100644 --- a/coderd/notifications/notifier.go +++ b/coderd/notifications/notifier.go @@ -51,6 +51,8 @@ func newNotifier(cfg codersdk.NotificationsConfig, id uuid.UUID, log slog.Logger // run is the main loop of the notifier. func (n *notifier) run(ctx context.Context, success chan<- dispatchResult, failure chan<- dispatchResult) error { + n.log.Info(ctx, "started") + defer func() { close(n.done) n.log.Info(context.Background(), "gracefully stopped") @@ -209,11 +211,23 @@ func (n *notifier) deliver(ctx context.Context, msg database.AcquireNotification return err } - logger.Warn(ctx, "message dispatch failed", slog.Error(err)) - failure <- newFailedDispatch(n.id, msg.ID, err, retryable) + select { + case <-ctx.Done(): + logger.Warn(context.Background(), "cannot record dispatch failure result", slog.Error(ctx.Err())) + return ctx.Err() + default: + logger.Warn(ctx, "message dispatch failed", slog.Error(err)) + failure <- newFailedDispatch(n.id, msg.ID, err, retryable) + } } else { - logger.Debug(ctx, "message dispatch succeeded") - success <- newSuccessfulDispatch(n.id, msg.ID) + select { + case <-ctx.Done(): + logger.Warn(context.Background(), "cannot record dispatch success result", slog.Error(ctx.Err())) + return ctx.Err() + default: + logger.Debug(ctx, "message dispatch succeeded") + success <- newSuccessfulDispatch(n.id, msg.ID) + } } return nil diff --git a/coderd/notifications/spec.go b/coderd/notifications/spec.go index 8f077e96905fa..63f6af7101d1b 100644 --- a/coderd/notifications/spec.go +++ b/coderd/notifications/spec.go @@ -20,12 +20,11 @@ type Store interface { BulkMarkNotificationMessagesFailed(ctx context.Context, arg database.BulkMarkNotificationMessagesFailedParams) (int64, error) 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) } // Handler is responsible for preparing and delivering a notification by a given method. type Handler interface { - NotificationMethod() database.NotificationMethod - // Dispatcher constructs a DeliveryFunc to be used for delivering a notification via the chosen method. Dispatcher(payload types.MessagePayload, title, body string) (dispatch.DeliveryFunc, error) } diff --git a/coderd/notifications/utils_test.go b/coderd/notifications/utils_test.go index 480dc2e05906d..12db76f5e48aa 100644 --- a/coderd/notifications/utils_test.go +++ b/coderd/notifications/utils_test.go @@ -10,16 +10,17 @@ 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/dbauthz" + "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" - "github.com/coder/coder/v2/coderd/database/pubsub" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" - "github.com/coder/serpent" ) -func setup(t *testing.T) (context.Context, slog.Logger, database.Store, *pubsub.PGPubsub) { +func setup(t *testing.T) (context.Context, slog.Logger, database.Store) { t.Helper() connectionURL, closeFunc, err := dbtestutil.Open() @@ -36,15 +37,8 @@ func setup(t *testing.T) (context.Context, slog.Logger, database.Store, *pubsub. require.NoError(t, sqlDB.Close()) }) - db := database.New(sqlDB) - ps, err := pubsub.New(ctx, logger, sqlDB, connectionURL) - require.NoError(t, err) - t.Cleanup(func() { - require.NoError(t, ps.Close()) - }) - // nolint:gocritic // unit tests. - return dbauthz.AsSystemRestricted(ctx), logger, db, ps + return dbauthz.AsSystemRestricted(ctx), logger, database.New(sqlDB) } func defaultNotificationsConfig(method database.NotificationMethod) codersdk.NotificationsConfig { @@ -68,3 +62,10 @@ func defaultHelpers() map[string]any { "base_url": func() string { return "http://test.com" }, } } + +func createSampleUser(t *testing.T, db database.Store) database.User { + return dbgen.User(t, db, database.User{ + Email: "bob@coder.com", + Username: "bob", + }) +} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 620bb7dda34ca..79185862daa2e 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1399,7 +1399,6 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) // This is for deleting a workspace! return nil } - s.notifyWorkspaceDeleted(ctx, workspace, workspaceBuild) err = db.UpdateWorkspaceDeletedByID(ctx, database.UpdateWorkspaceDeletedByIDParams{ ID: workspaceBuild.WorkspaceID, @@ -1417,6 +1416,11 @@ func (s *server) CompleteJob(ctx context.Context, completed *proto.CompletedJob) // audit the outcome of the workspace build if getWorkspaceError == nil { + // If the workspace has been deleted, notify the owner about it. + if workspaceBuild.Transition == database.WorkspaceTransitionDelete { + s.notifyWorkspaceDeleted(ctx, workspace, workspaceBuild) + } + auditor := s.Auditor.Load() auditAction := auditActionFromTransition(workspaceBuild.Transition) @@ -1527,18 +1531,23 @@ func (s *server) notifyWorkspaceDeleted(ctx context.Context, workspace database. return } - reason = fmt.Sprintf("initiated by _%s_", build.InitiatorByUsername) + reason = "initiated by user" case database.BuildReasonAutodelete: reason = "autodeleted due to dormancy" default: reason = string(build.Reason) } + } else { + reason = string(build.Reason) + s.Logger.Warn(ctx, "invalid build reason when sending deletion notification", + slog.F("reason", reason), slog.F("workspace_id", workspace.ID), slog.F("build_id", build.ID)) } if _, err := s.NotificationEnqueuer.Enqueue(ctx, workspace.OwnerID, notifications.TemplateWorkspaceDeleted, map[string]string{ - "name": workspace.Name, - "reason": reason, + "name": workspace.Name, + "initiatedBy": build.InitiatorByUsername, + "reason": reason, }, "provisionerdserver", // Associate this notification with all the related entities. workspace.ID, workspace.OwnerID, workspace.TemplateID, workspace.OrganizationID, diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index 4cbc3be4299a1..7049359be98a7 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -4,7 +4,6 @@ import ( "context" "database/sql" "encoding/json" - "fmt" "io" "net/url" "strings" @@ -1580,18 +1579,18 @@ func TestNotifications(t *testing.T) { shouldSelfInitiate bool }{ { - name: "Deletion initiated by autodelete should enqueue a notification", + name: "initiated by autodelete", deletionReason: database.BuildReasonAutodelete, shouldNotify: true, }, { - name: "Deletion initiated by self should not enqueue a notification", + name: "initiated by self", deletionReason: database.BuildReasonInitiator, shouldNotify: false, shouldSelfInitiate: true, }, { - name: "Deletion initiated by someone else should enqueue a notification", + name: "initiated by someone else", deletionReason: database.BuildReasonInitiator, shouldNotify: true, shouldSelfInitiate: false, @@ -1661,13 +1660,6 @@ func TestNotifications(t *testing.T) { }) require.NoError(t, err) - publishedWorkspace := make(chan struct{}) - closeWorkspaceSubscribe, err := ps.Subscribe(codersdk.WorkspaceNotifyChannel(build.WorkspaceID), func(_ context.Context, _ []byte) { - close(publishedWorkspace) - }) - require.NoError(t, err) - defer closeWorkspaceSubscribe() - _, err = srv.CompleteJob(ctx, &proto.CompletedJob{ JobId: job.ID.String(), Type: &proto.CompletedJob_WorkspaceBuild_{ @@ -1682,8 +1674,6 @@ func TestNotifications(t *testing.T) { }) require.NoError(t, err) - <-publishedWorkspace - workspace, err = db.GetWorkspaceByID(ctx, workspace.ID) require.NoError(t, err) require.True(t, workspace.Deleted) @@ -1697,7 +1687,7 @@ func TestNotifications(t *testing.T) { require.Contains(t, notifEnq.sent[0].targets, workspace.OrganizationID) require.Contains(t, notifEnq.sent[0].targets, user.ID) if tc.deletionReason == database.BuildReasonInitiator { - require.Equal(t, notifEnq.sent[0].labels["reason"], fmt.Sprintf("initiated by _%s_", initiator.Username)) + require.Equal(t, notifEnq.sent[0].labels["initiatedBy"], initiator.Username) } } else { require.Len(t, notifEnq.sent, 0) From c6e75c290320841773ddc5438cc16fdbe07c50fe Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 4 Jul 2024 10:26:07 +0200 Subject: [PATCH 24/26] Fix lint failures Signed-off-by: Danny Kopping --- coderd/database/dbauthz/dbauthz_test.go | 8 ++++++++ coderd/notifications/manager.go | 4 +--- flake.nix | 2 +- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index 57b03a40fba73..d85192877f87a 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -13,6 +13,7 @@ import ( "golang.org/x/xerrors" "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/database/db2sdk" "github.com/coder/coder/v2/coderd/rbac/policy" "github.com/coder/coder/v2/codersdk" @@ -2494,6 +2495,13 @@ func (s *MethodTestSuite) TestSystemFunctions() { // TODO: update this test once we have a specific role for notifications check.Args(database.FetchNewMessageMetadataParams{}).Asserts(rbac.ResourceSystem, policy.ActionRead) })) + s.Run("GetNotificationMessagesByStatus", s.Subtest(func(db database.Store, check *expects) { + // TODO: update this test once we have a specific role for notifications + check.Args(database.GetNotificationMessagesByStatusParams{ + Status: database.NotificationMessageStatusLeased, + Limit: 10, + }).Asserts(rbac.ResourceSystem, policy.ActionRead) + })) } func (s *MethodTestSuite) TestOAuth2ProviderApps() { diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index d104fb783e966..2b7afe9bbff01 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -16,9 +16,7 @@ import ( "github.com/coder/coder/v2/codersdk" ) -var ( - ErrInvalidDispatchTimeout = xerrors.New("dispatch timeout must be less than lease period") -) +var ErrInvalidDispatchTimeout = xerrors.New("dispatch timeout must be less than lease period") // Manager manages all notifications being enqueued and dispatched. // diff --git a/flake.nix b/flake.nix index 930294b71a8b4..6e1aa4a5ffe51 100644 --- a/flake.nix +++ b/flake.nix @@ -97,7 +97,7 @@ name = "coder-${osArch}"; # Updated with ./scripts/update-flake.sh`. # This should be updated whenever go.mod changes! - vendorHash = "sha256-xHrnqSq2Ya04d9Y48tbkQTNo9bYnp7LqcUnXXRbMFXE="; + vendorHash = "sha256-HXDei93ALEImIMgX3Ez829jmJJsf46GwaqPDlleQFmk="; proxyVendor = true; src = ./.; nativeBuildInputs = with pkgs; [ getopt openssl zstd ]; From aff9e6cd52c2bb1cd4ecc124ebbb709508048371 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 4 Jul 2024 11:15:40 +0200 Subject: [PATCH 25/26] Review comments Signed-off-by: Danny Kopping --- coderd/database/migrations/000221_notifications.up.sql | 2 +- coderd/notifications/dispatch/webhook.go | 4 ++-- coderd/notifications/events.go | 7 +++++-- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/coderd/database/migrations/000221_notifications.up.sql b/coderd/database/migrations/000221_notifications.up.sql index 3e1ed645fe40d..29a6b912d3e20 100644 --- a/coderd/database/migrations/000221_notifications.up.sql +++ b/coderd/database/migrations/000221_notifications.up.sql @@ -52,7 +52,7 @@ CREATE INDEX idx_notification_messages_status ON notification_messages (status); -- TODO: autogenerate constants which reference the UUIDs INSERT INTO notification_templates (id, name, title_template, body_template, "group", actions) VALUES ('f517da0b-cdc9-410f-ab89-a86107c420ed', 'Workspace Deleted', E'Workspace "{{.Labels.name}}" deleted', - E'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}{{ if .Labels.initiatedBy }} ({{ .Labels.initiatedBy }}){{end}}**".', + E'Hi {{.UserName}}\n\nYour workspace **{{.Labels.name}}** was deleted.\nThe specified reason was "**{{.Labels.reason}}{{ if .Labels.initiator }} ({{ .Labels.initiator }}){{end}}**".', 'Workspace Events', '[ { "label": "View workspaces", diff --git a/coderd/notifications/dispatch/webhook.go b/coderd/notifications/dispatch/webhook.go index 5c4d978919a16..c1fb47ea35692 100644 --- a/coderd/notifications/dispatch/webhook.go +++ b/coderd/notifications/dispatch/webhook.go @@ -88,8 +88,8 @@ func (w *WebhookHandler) dispatch(msgPayload types.MessagePayload, title, body, // Handle response. if resp.StatusCode/100 > 2 { - // Body could be quite long here, let's grab the first 500B and hope it contains useful debug info. - respBody := make([]byte, 500) + // Body could be quite long here, let's grab the first 512B and hope it contains useful debug info. + respBody := make([]byte, 512) lr := io.LimitReader(resp.Body, int64(len(respBody))) n, err := lr.Read(respBody) if err != nil && !errors.Is(err, io.EOF) { diff --git a/coderd/notifications/events.go b/coderd/notifications/events.go index d66b4bd67b675..6cb2870748b61 100644 --- a/coderd/notifications/events.go +++ b/coderd/notifications/events.go @@ -2,5 +2,8 @@ package notifications import "github.com/google/uuid" -// Workspaces. -var TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed") // ... +// These vars are mapped to UUIDs in the notification_templates table. +// TODO: autogenerate these. + +// Workspace-related events. +var TemplateWorkspaceDeleted = uuid.MustParse("f517da0b-cdc9-410f-ab89-a86107c420ed") From 613e07444884ca33fadd3bd60fdb6452e18e84b4 Mon Sep 17 00:00:00 2001 From: Danny Kopping Date: Thu, 4 Jul 2024 12:13:34 +0200 Subject: [PATCH 26/26] Avoid race by exposing number of pending updates Signed-off-by: Danny Kopping --- coderd/notifications/manager.go | 52 ++++++++++++++++------------ coderd/notifications/manager_test.go | 33 ++++++++++++++---- 2 files changed, 55 insertions(+), 30 deletions(-) diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 2b7afe9bbff01..36e82d65af31b 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -44,6 +44,8 @@ type Manager struct { notifier *notifier handlers map[database.NotificationMethod]Handler + success, failure chan dispatchResult + runOnce sync.Once stopOnce sync.Once stop chan any @@ -67,6 +69,15 @@ func NewManager(cfg codersdk.NotificationsConfig, store Store, log slog.Logger) cfg: cfg, store: store, + // Buffer successful/failed notification dispatches in memory to reduce load on the store. + // + // We keep separate buffered for success/failure right now because the bulk updates are already a bit janky, + // see BulkMarkNotificationMessagesSent/BulkMarkNotificationMessagesFailed. If we had the ability to batch updates, + // like is offered in https://docs.sqlc.dev/en/stable/reference/query-annotations.html#batchmany, we'd have a cleaner + // approach to this - but for now this will work fine. + success: make(chan dispatchResult, cfg.StoreSyncBufferSize), + failure: make(chan dispatchResult, cfg.StoreSyncBufferSize), + stop: make(chan any), done: make(chan any), @@ -123,23 +134,12 @@ func (m *Manager) loop(ctx context.Context) error { default: } - var ( - // Buffer successful/failed notification dispatches in memory to reduce load on the store. - // - // We keep separate buffered for success/failure right now because the bulk updates are already a bit janky, - // see BulkMarkNotificationMessagesSent/BulkMarkNotificationMessagesFailed. If we had the ability to batch updates, - // like is offered in https://docs.sqlc.dev/en/stable/reference/query-annotations.html#batchmany, we'd have a cleaner - // approach to this - but for now this will work fine. - success = make(chan dispatchResult, m.cfg.StoreSyncBufferSize) - failure = make(chan dispatchResult, m.cfg.StoreSyncBufferSize) - ) - var eg errgroup.Group // Create a notifier to run concurrently, which will handle dequeueing and dispatching notifications. m.notifier = newNotifier(m.cfg, uuid.New(), m.log, m.store, m.handlers) eg.Go(func() error { - return m.notifier.run(ctx, success, failure) + return m.notifier.run(ctx, m.success, m.failure) }) // Periodically flush notification state changes to the store. @@ -162,21 +162,21 @@ func (m *Manager) loop(ctx context.Context) error { // TODO: mention the above tradeoff in documentation. m.log.Warn(ctx, "exiting ungracefully", slog.Error(ctx.Err())) - if len(success)+len(failure) > 0 { + if len(m.success)+len(m.failure) > 0 { m.log.Warn(ctx, "content canceled with pending updates in buffer, these messages will be sent again after lease expires", - slog.F("success_count", len(success)), slog.F("failure_count", len(failure))) + slog.F("success_count", len(m.success)), slog.F("failure_count", len(m.failure))) } return ctx.Err() case <-m.stop: - if len(success)+len(failure) > 0 { + if len(m.success)+len(m.failure) > 0 { m.log.Warn(ctx, "flushing buffered updates before stop", - slog.F("success_count", len(success)), slog.F("failure_count", len(failure))) - m.bulkUpdate(ctx, success, failure) + slog.F("success_count", len(m.success)), slog.F("failure_count", len(m.failure))) + m.bulkUpdate(ctx) m.log.Warn(ctx, "flushing updates done") } return nil case <-tick.C: - m.bulkUpdate(ctx, success, failure) + m.bulkUpdate(ctx) } } }) @@ -188,16 +188,22 @@ func (m *Manager) loop(ctx context.Context) error { return err } +// BufferedUpdatesCount returns the number of buffered updates which are currently waiting to be flushed to the store. +// The returned values are for success & failure, respectively. +func (m *Manager) BufferedUpdatesCount() (success int, failure int) { + return len(m.success), len(m.failure) +} + // bulkUpdate updates messages in the store based on the given successful and failed message dispatch results. -func (m *Manager) bulkUpdate(ctx context.Context, success, failure <-chan dispatchResult) { +func (m *Manager) bulkUpdate(ctx context.Context) { select { case <-ctx.Done(): return default: } - nSuccess := len(success) - nFailure := len(failure) + nSuccess := len(m.success) + nFailure := len(m.failure) // Nothing to do. if nSuccess+nFailure == 0 { @@ -217,12 +223,12 @@ func (m *Manager) bulkUpdate(ctx context.Context, success, failure <-chan dispat // will be processed on the next bulk update. for i := 0; i < nSuccess; i++ { - res := <-success + res := <-m.success successParams.IDs = append(successParams.IDs, res.msg) successParams.SentAts = append(successParams.SentAts, res.ts) } for i := 0; i < nFailure; i++ { - res := <-failure + res := <-m.failure status := database.NotificationMessageStatusPermanentFailure if res.retryable { diff --git a/coderd/notifications/manager_test.go b/coderd/notifications/manager_test.go index d5b4d0df615b6..d0d6355f0c68c 100644 --- a/coderd/notifications/manager_test.go +++ b/coderd/notifications/manager_test.go @@ -5,7 +5,9 @@ import ( "encoding/json" "sync/atomic" "testing" + "time" + "github.com/coder/serpent" "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -31,11 +33,14 @@ func TestBufferedUpdates(t *testing.T) { if !dbtestutil.WillUsePostgres() { t.Skip("This test requires postgres") } + ctx, logger, db := setup(t) interceptor := &bulkUpdateInterceptor{Store: db} - santa := &santaHandler{} + cfg := defaultNotificationsConfig(database.NotificationMethodSmtp) + cfg.StoreSyncInterval = serpent.Duration(time.Hour) // Ensure we don't sync the store automatically. + mgr, err := notifications.NewManager(cfg, interceptor, logger.Named("notifications-manager")) require.NoError(t, err) mgr.WithHandlers(map[database.NotificationMethod]notifications.Handler{ @@ -47,11 +52,11 @@ func TestBufferedUpdates(t *testing.T) { user := dbgen.User(t, db, database.User{}) // given - _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, "") + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, "") // Will succeed. require.NoError(t, err) - _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, "") + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "true"}, "") // Will succeed. require.NoError(t, err) - _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "false"}, "") + _, err = enq.Enqueue(ctx, user.ID, notifications.TemplateWorkspaceDeleted, map[string]string{"nice": "false"}, "") // Will fail. require.NoError(t, err) // when @@ -59,8 +64,22 @@ func TestBufferedUpdates(t *testing.T) { // then + const ( + expectedSuccess = 2 + expectedFailure = 1 + ) + // Wait for messages to be dispatched. - require.Eventually(t, func() bool { return santa.naughty.Load() == 1 && santa.nice.Load() == 2 }, testutil.WaitMedium, testutil.IntervalFast) + require.Eventually(t, func() bool { + return santa.naughty.Load() == expectedFailure && + santa.nice.Load() == expectedSuccess + }, testutil.WaitMedium, testutil.IntervalFast) + + // Wait for the expected number of buffered updates to be accumulated. + require.Eventually(t, func() bool { + success, failure := mgr.BufferedUpdatesCount() + return success == expectedSuccess && failure == expectedFailure + }, testutil.WaitShort, testutil.IntervalFast) // Stop the manager which forces an update of buffered updates. require.NoError(t, mgr.Stop(ctx)) @@ -73,8 +92,8 @@ func TestBufferedUpdates(t *testing.T) { ct.FailNow() } - assert.EqualValues(ct, 1, interceptor.failed.Load()) - assert.EqualValues(ct, 2, interceptor.sent.Load()) + assert.EqualValues(ct, expectedFailure, interceptor.failed.Load()) + assert.EqualValues(ct, expectedSuccess, interceptor.sent.Load()) }, testutil.WaitMedium, testutil.IntervalFast) }