diff --git a/cli/notifications_test.go b/cli/notifications_test.go index 9d7ff8a37abc3..9d775c6f5842b 100644 --- a/cli/notifications_test.go +++ b/cli/notifications_test.go @@ -20,7 +20,6 @@ func createOpts(t *testing.T) *coderdtest.Options { t.Helper() dt := coderdtest.DeploymentValues(t) - dt.Experiments = []string{string(codersdk.ExperimentNotifications)} return &coderdtest.Options{ DeploymentValues: dt, } diff --git a/cli/server.go b/cli/server.go index 561c1bac16375..612548372c00a 100644 --- a/cli/server.go +++ b/cli/server.go @@ -56,15 +56,16 @@ import ( "cdr.dev/slog" "cdr.dev/slog/sloggers/sloghuman" - "github.com/coder/coder/v2/coderd/entitlements" - "github.com/coder/coder/v2/coderd/notifications/reports" - "github.com/coder/coder/v2/coderd/runtimeconfig" "github.com/coder/pretty" "github.com/coder/quartz" "github.com/coder/retry" "github.com/coder/serpent" "github.com/coder/wgtunnel/tunnelsdk" + "github.com/coder/coder/v2/coderd/entitlements" + "github.com/coder/coder/v2/coderd/notifications/reports" + "github.com/coder/coder/v2/coderd/runtimeconfig" + "github.com/coder/coder/v2/buildinfo" "github.com/coder/coder/v2/cli/clilog" "github.com/coder/coder/v2/cli/cliui" @@ -679,10 +680,6 @@ 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. @@ -946,6 +943,33 @@ func (r *RootCmd) Server(newAPI func(context.Context, *coderd.Options) (*coderd. return xerrors.Errorf("write config url: %w", err) } + // Manage notifications. + cfg := options.DeploymentValues.Notifications + metrics := notifications.NewMetrics(options.PrometheusRegistry) + helpers := templateHelpers(options) + + // The enqueuer is responsible for enqueueing notifications to the given store. + enqueuer, err := notifications.NewStoreEnqueuer(cfg, options.Database, helpers, logger.Named("notifications.enqueuer"), quartz.NewReal()) + 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, helpers, metrics, 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)) + + // Run report generator to distribute periodic reports. + notificationReportGenerator := reports.NewReportGenerator(ctx, logger.Named("notifications.report_generator"), options.Database, options.NotificationsEnqueuer, quartz.NewReal()) + defer notificationReportGenerator.Close() + // Since errCh only has one buffered slot, all routines // sending on it must be wrapped in a select/default to // avoid leaving dangling goroutines waiting for the @@ -1002,38 +1026,6 @@ 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 - metrics := notifications.NewMetrics(options.PrometheusRegistry) - helpers := templateHelpers(options) - - // The enqueuer is responsible for enqueueing notifications to the given store. - enqueuer, err := notifications.NewStoreEnqueuer(cfg, options.Database, helpers, logger.Named("notifications.enqueuer"), quartz.NewReal()) - 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, helpers, metrics, 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)) - - // Run report generator to distribute periodic reports. - notificationReportGenerator := reports.NewReportGenerator(ctx, logger.Named("notifications.report_generator"), options.Database, options.NotificationsEnqueuer, quartz.NewReal()) - defer notificationReportGenerator.Close() - } - // 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 @@ -1153,19 +1145,17 @@ 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") - } + // 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 diff --git a/coderd/coderd.go b/coderd/coderd.go index 83a780474825b..cbe008a726636 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -37,11 +37,12 @@ import ( "tailscale.com/util/singleflight" "cdr.dev/slog" + "github.com/coder/quartz" + "github.com/coder/serpent" + "github.com/coder/coder/v2/coderd/entitlements" "github.com/coder/coder/v2/coderd/idpsync" "github.com/coder/coder/v2/coderd/runtimeconfig" - "github.com/coder/quartz" - "github.com/coder/serpent" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/buildinfo" @@ -1257,10 +1258,7 @@ func New(options *Options) *API { }) }) r.Route("/notifications", func(r chi.Router) { - r.Use( - apiKeyMiddleware, - httpmw.RequireExperiment(api.Experiments, codersdk.ExperimentNotifications), - ) + r.Use(apiKeyMiddleware) r.Get("/settings", api.notificationsSettings) r.Put("/settings", api.putNotificationsSettings) r.Route("/templates", func(r chi.Router) { diff --git a/coderd/notifications/manager.go b/coderd/notifications/manager.go index 3c983b2b3ee3d..8b765bbe88c33 100644 --- a/coderd/notifications/manager.go +++ b/coderd/notifications/manager.go @@ -54,6 +54,7 @@ type Manager struct { runOnce sync.Once stopOnce sync.Once + doneOnce sync.Once stop chan any done chan any @@ -153,7 +154,9 @@ func (m *Manager) Run(ctx context.Context) { // 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.doneOnce.Do(func() { + close(m.done) + }) m.log.Info(context.Background(), "notification manager stopped") }() @@ -364,7 +367,9 @@ func (m *Manager) Stop(ctx context.Context) error { // 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 m.notifier == nil { - close(m.done) + m.doneOnce.Do(func() { + close(m.done) + }) } else { m.notifier.stop() } diff --git a/coderd/notifications/notifications_test.go b/coderd/notifications/notifications_test.go index 6cc9c9467e9fd..ca1f4f78aad72 100644 --- a/coderd/notifications/notifications_test.go +++ b/coderd/notifications/notifications_test.go @@ -1187,7 +1187,6 @@ func createOpts(t *testing.T) *coderdtest.Options { t.Helper() dt := coderdtest.DeploymentValues(t) - dt.Experiments = []string{string(codersdk.ExperimentNotifications)} return &coderdtest.Options{ DeploymentValues: dt, } diff --git a/coderd/notifications/reports/generator.go b/coderd/notifications/reports/generator.go index 0e5372aa8a894..2424498146c60 100644 --- a/coderd/notifications/reports/generator.go +++ b/coderd/notifications/reports/generator.go @@ -49,7 +49,7 @@ func NewReportGenerator(ctx context.Context, logger slog.Logger, db database.Sto return nil } - err = reportFailedWorkspaceBuilds(ctx, logger, db, enqueuer, clk) + err = reportFailedWorkspaceBuilds(ctx, logger, tx, enqueuer, clk) if err != nil { return xerrors.Errorf("unable to generate reports with failed workspace builds: %w", err) } diff --git a/coderd/notifications_test.go b/coderd/notifications_test.go index 17598cd812f7f..c4f0a551d4914 100644 --- a/coderd/notifications_test.go +++ b/coderd/notifications_test.go @@ -20,7 +20,6 @@ func createOpts(t *testing.T) *coderdtest.Options { t.Helper() dt := coderdtest.DeploymentValues(t) - dt.Experiments = []string{string(codersdk.ExperimentNotifications)} return &coderdtest.Options{ DeploymentValues: dt, } diff --git a/codersdk/deployment.go b/codersdk/deployment.go index da4f3daabea06..ed4d66001d8d6 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -2901,7 +2901,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{ExperimentNotifications} +var ExperimentsAll = Experiments{} // Experiments is a list of experiments. // Multiple experiments may be enabled at the same time. diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 5127e6ec0887f..e0b8170c6ec2a 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -448,7 +448,6 @@ func New(ctx context.Context, options *Options) (_ *API, err error) { // with the below route, we need to register this route without any mounts or groups to make both work. r.With( apiKeyMiddleware, - httpmw.RequireExperiment(api.AGPL.Experiments, codersdk.ExperimentNotifications), httpmw.ExtractNotificationTemplateParam(options.Database), ).Put("/notifications/templates/{notification_template}/method", api.updateNotificationTemplateMethod) }) diff --git a/enterprise/coderd/notifications_test.go b/enterprise/coderd/notifications_test.go index 5546bec1dcb79..b71bde86a5736 100644 --- a/enterprise/coderd/notifications_test.go +++ b/enterprise/coderd/notifications_test.go @@ -23,7 +23,6 @@ func createOpts(t *testing.T) *coderdenttest.Options { t.Helper() dt := coderdtest.DeploymentValues(t) - dt.Experiments = []string{string(codersdk.ExperimentNotifications)} return &coderdenttest.Options{ Options: &coderdtest.Options{ DeploymentValues: dt, diff --git a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx index 52cdfaeb01a11..763b180d03bbe 100644 --- a/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx +++ b/site/src/components/FeatureStageBadge/FeatureStageBadge.tsx @@ -10,7 +10,7 @@ import { docs } from "utils/docs"; * All types of feature that we are currently supporting. Defined as record to * ensure that we can't accidentally make typos when writing the badge text. */ -const featureStageBadgeTypes = { +export const featureStageBadgeTypes = { beta: "beta", experimental: "experimental", } as const satisfies Record; diff --git a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx index c073792248072..511959d3ec55f 100644 --- a/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/DeploySettingsPage/NotificationsPage/NotificationsPage.tsx @@ -43,6 +43,7 @@ export const NotificationsPage: FC = () => { title="Notifications" description="Control delivery methods for notifications on this deployment." layout="fluid" + featureStage={"beta"} > diff --git a/site/src/pages/DeploySettingsPage/Sidebar.tsx b/site/src/pages/DeploySettingsPage/Sidebar.tsx index 607920d65ee2f..1f1172834a5fa 100644 --- a/site/src/pages/DeploySettingsPage/Sidebar.tsx +++ b/site/src/pages/DeploySettingsPage/Sidebar.tsx @@ -7,6 +7,7 @@ import NotificationsIcon from "@mui/icons-material/NotificationsNoneOutlined"; import Globe from "@mui/icons-material/PublicOutlined"; import ApprovalIcon from "@mui/icons-material/VerifiedUserOutlined"; import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { GitIcon } from "components/Icons/GitIcon"; import { Sidebar as BaseSidebar, @@ -51,11 +52,9 @@ export const Sidebar: FC = () => { Observability - {experiments.includes("notifications") && ( - - Notifications - - )} + + Notifications + ); }; diff --git a/site/src/pages/ManagementSettingsPage/SidebarView.tsx b/site/src/pages/ManagementSettingsPage/SidebarView.tsx index f76b4da5b339f..37db583f23ba0 100644 --- a/site/src/pages/ManagementSettingsPage/SidebarView.tsx +++ b/site/src/pages/ManagementSettingsPage/SidebarView.tsx @@ -148,11 +148,12 @@ const DeploymentSettingsNavigation: FC = ({ Users )} - {experiments.includes("notifications") && ( + Notifications - )} + + )} diff --git a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx index 49f01f1f00936..c67737fc00530 100644 --- a/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx +++ b/site/src/pages/UserSettingsPage/NotificationsPage/NotificationsPage.tsx @@ -99,6 +99,7 @@ export const NotificationsPage: FC = () => { title="Notifications" description="Configure your notification preferences. Icons on the right of each notification indicate delivery method, either SMTP or Webhook." layout="fluid" + featureStage="beta" > {ready ? ( diff --git a/site/src/pages/UserSettingsPage/Section.tsx b/site/src/pages/UserSettingsPage/Section.tsx index edc2740537fbc..8c52aca1eb9cb 100644 --- a/site/src/pages/UserSettingsPage/Section.tsx +++ b/site/src/pages/UserSettingsPage/Section.tsx @@ -1,4 +1,9 @@ import type { Interpolation, Theme } from "@emotion/react"; +import { + FeatureStageBadge, + type featureStageBadgeTypes, +} from "components/FeatureStageBadge/FeatureStageBadge"; +import { Stack } from "components/Stack/Stack"; import type { FC, ReactNode } from "react"; type SectionLayout = "fixed" | "fluid"; @@ -13,6 +18,7 @@ export interface SectionProps { layout?: SectionLayout; className?: string; children?: ReactNode; + featureStage?: keyof typeof featureStageBadgeTypes; } export const Section: FC = ({ @@ -24,6 +30,7 @@ export const Section: FC = ({ className = "", children, layout = "fixed", + featureStage, }) => { return (
@@ -32,16 +39,25 @@ export const Section: FC = ({
{title && ( -

- {title} -

+ +

+ {title} +

+ {featureStage && ( + + )} +
)} {description && typeof description === "string" && (

{description}

diff --git a/site/src/pages/UserSettingsPage/Sidebar.tsx b/site/src/pages/UserSettingsPage/Sidebar.tsx index 2580e00f02e07..196f34d5ce0e1 100644 --- a/site/src/pages/UserSettingsPage/Sidebar.tsx +++ b/site/src/pages/UserSettingsPage/Sidebar.tsx @@ -6,6 +6,7 @@ import NotificationsIcon from "@mui/icons-material/NotificationsNoneOutlined"; import AccountIcon from "@mui/icons-material/Person"; import VpnKeyOutlined from "@mui/icons-material/VpnKeyOutlined"; import type { User } from "api/typesGenerated"; +import { FeatureStageBadge } from "components/FeatureStageBadge/FeatureStageBadge"; import { GitIcon } from "components/Icons/GitIcon"; import { Sidebar as BaseSidebar, @@ -57,11 +58,9 @@ export const Sidebar: FC = ({ user }) => { Tokens - {experiments.includes("notifications") && ( - - Notifications - - )} + + Notifications + ); };