diff --git a/.github/discord-screenshot.png b/.github/discord-screenshot.png index a4fa875..8696c97 100644 Binary files a/.github/discord-screenshot.png and b/.github/discord-screenshot.png differ diff --git a/README.md b/README.md index 2101e4e..8297894 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ This program is not a replacement to alertmanager, it accepts webhooks from aler The standard "dataflow" should be: -``` +```text Prometheus -------------> alertmanager -------------------> alertmanager-discord alerting: receivers: @@ -18,11 +18,6 @@ alerting: receivers: - static_configs: webhook_configs: - DISCORD_WEBHOOK=https://discordapp.com/api/we... - targets: - url: 'http://localhost:9094' - 127.0.0.1:9093 - - - - - ``` ## Features @@ -44,30 +39,30 @@ alerting: receivers: ## Example alertmanager config -``` +```yaml global: # The smarthost and SMTP sender used for mail notifications. - smtp_smarthost: 'localhost:25' - smtp_from: 'alertmanager@example.org' - smtp_auth_username: 'alertmanager' - smtp_auth_password: 'password' + smtp_smarthost: "localhost:25" + smtp_from: "alertmanager@example.org" + smtp_auth_username: "alertmanager" + smtp_auth_password: "password" # The directory from which notification templates are read. templates: -- '/etc/alertmanager/template/*.tmpl' + - "/etc/alertmanager/template/*.tmpl" # The root route on which each incoming alert enters. route: - group_by: ['alertname'] + group_by: ["alertname"] group_wait: 20s group_interval: 5m repeat_interval: 3h receiver: discord_webhook receivers: -- name: 'discord_webhook' - webhook_configs: - - url: 'http://localhost:9094' + - name: "discord_webhook" + webhook_configs: + - url: "http://localhost:9094" ``` ## Deployment @@ -88,9 +83,9 @@ discord_webhook_url: https://discord.com/api/webhooks/123456789123456789/abc go run . --configuration_file_path=/path/to/your/config.yaml ``` -### Docker +### Docker or OCI-compatible container runtime -If you wish to deploy this to docker infra, you can find the docker hub repo here: https://hub.docker.com/r/speckle/alertmanager-discord/ +If you wish to deploy this to Docker, or similar OCI-compatible container runtime, you can pull the OCI image from the [Docker Hub repository](https://hub.docker.com/r/speckle/alertmanager-discord/). ### Kubernetes Helm Chart @@ -169,4 +164,4 @@ go test ./... -v -cover -test.shuffle on ## Acknowledgements -This repository is forked from https://github.com/benjojo/alertmanager-discord under the Apache 2.0 license +This repository is forked from [benjojo/alertmanager-discord](https://github.com/benjojo/alertmanager-discord) under the Apache 2.0 license diff --git a/cmd/root.go b/cmd/root.go index c1d63d8..67f7ee4 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -5,7 +5,7 @@ import ( "strings" "time" - . "github.com/specklesystems/alertmanager-discord/pkg/flags" + "github.com/specklesystems/alertmanager-discord/pkg/flags" "github.com/specklesystems/alertmanager-discord/pkg/server" "github.com/specklesystems/alertmanager-discord/pkg/version" @@ -18,35 +18,30 @@ import ( const ( defaultConfigurationPath = "/etc/alertmanager-discord/config.yaml" defaultMaxBackoffTimeSeconds = 10 + defaultLogLevel = "info" ) var ( configurationFilePath string webhookURL string listenAddress string + logLevel string maximumBackoffTimeSeconds int ) func init() { - viper.SetDefault(ConfigurationPathFlagKey, defaultConfigurationPath) + defineConfigurationVariable(&configurationFilePath, rootCmd.Flags().StringVarP, flags.ConfigurationPathFlagKey, "c", defaultConfigurationPath, "Path to the configuration file.") + defineConfigurationVariable(&webhookURL, rootCmd.Flags().StringVarP, flags.DiscordWebhookUrlFlagKey, "d", "", "Url to the Discord webhook API endpoint.") + defineConfigurationVariable(&listenAddress, rootCmd.Flags().StringVarP, flags.ListenAddressFlagKey, "l", server.DefaultListenAddress, "The address (host:port) which the server will attempt to bind to and listen on.") + defineConfigurationVariable(&logLevel, rootCmd.Flags().StringVarP, flags.LogLevelFlagKey, "", defaultLogLevel, "The minimum level of logging to be produced by the pod. Acceptable values, in ascending order, are 'trace', 'debug', 'info', 'warn', 'error', 'fatal', 'panic', or 'disabled'.") + defineConfigurationVariable(&maximumBackoffTimeSeconds, rootCmd.Flags().IntVarP, flags.MaxBackoffTimeSecondsFlagKey, "", defaultMaxBackoffTimeSeconds, "The maximum elapsed duration (expressed as an integer number of seconds) to allow the Discord client to continue retrying to send messages to the Discord API.") +} - viper.BindEnv(ConfigurationPathFlagKey, strings.ToUpper(ConfigurationPathFlagKey)) - rootCmd.Flags().StringVarP(&configurationFilePath, ConfigurationPathFlagKey, "c", defaultConfigurationPath, "Path to the configuration file.") - viper.BindPFlag(ConfigurationPathFlagKey, rootCmd.Flags().Lookup(ConfigurationPathFlagKey)) - - viper.BindEnv(DiscordWebhookUrlFlagKey, strings.ToUpper(DiscordWebhookUrlFlagKey)) - rootCmd.Flags().StringVarP(&webhookURL, DiscordWebhookUrlFlagKey, "d", "", "Url to the Discord webhook API endpoint.") - viper.BindPFlag(DiscordWebhookUrlFlagKey, rootCmd.Flags().Lookup(DiscordWebhookUrlFlagKey)) - - viper.SetDefault(ListenAddressFlagKey, server.DefaultListenAddress) - viper.BindEnv(ListenAddressFlagKey, strings.ToUpper(ListenAddressFlagKey)) - rootCmd.Flags().StringVarP(&listenAddress, ListenAddressFlagKey, "l", "", "The address (host:port) which the server will attempt to bind to and listen on.") - viper.BindPFlag(ListenAddressFlagKey, rootCmd.Flags().Lookup(ListenAddressFlagKey)) - - viper.SetDefault(MaxBackoffTimeSecondsFlagKey, defaultMaxBackoffTimeSeconds) - viper.BindEnv(MaxBackoffTimeSecondsFlagKey, strings.ToUpper(MaxBackoffTimeSecondsFlagKey)) - rootCmd.Flags().IntVarP(&maximumBackoffTimeSeconds, MaxBackoffTimeSecondsFlagKey, "", defaultMaxBackoffTimeSeconds, "The maximum elapsed duration (expressed as an integer number of seconds) to allow the Discord client to continue retrying to send messages to the Discord API.") - viper.BindPFlag(MaxBackoffTimeSecondsFlagKey, rootCmd.Flags().Lookup(MaxBackoffTimeSecondsFlagKey)) +func defineConfigurationVariable[K int | string](variable *K, flagParser func(*K, string, string, K, string), flagKey string, shorthand string, defaultValue K, description string) { + viper.SetDefault(flagKey, defaultValue) + viper.BindEnv(flagKey, strings.ToUpper(flagKey)) + flagParser(variable, flagKey, shorthand, defaultValue, description) + viper.BindPFlag(flagKey, rootCmd.Flags().Lookup(flagKey)) } var rootCmd = &cobra.Command{ @@ -57,22 +52,27 @@ var rootCmd = &cobra.Command{ translates the data to match Discord's message specifications, and forwards that to Discord's message API endpoint.`, Run: func(cmd *cobra.Command, args []string) { - zerolog.TimeFieldFormat = zerolog.TimeFormatUnix + zerolog.TimeFieldFormat = time.RFC3339 + zerolog.SetGlobalLevel(zerolog.InfoLevel) + // these log messages are generated before the log level is set log.Debug().Msgf("Attempting to read from configuration file path: ('%s')", configurationFilePath) viper.SetConfigFile(configurationFilePath) if err := viper.ReadInConfig(); err != nil { log.Info().Err(err).Msgf("Unable to read configuration file at path ('%s'). Attempting to parse command line arguments or environment variables, the command line argument has higher order of precedence.", configurationFilePath) } - if viper.GetString(DiscordWebhookUrlFlagKey) != "" { - webhookURL = viper.GetString(DiscordWebhookUrlFlagKey) + if viper.GetString(flags.DiscordWebhookUrlFlagKey) != "" { + webhookURL = viper.GetString(flags.DiscordWebhookUrlFlagKey) } - if viper.GetString(ListenAddressFlagKey) != "" { - listenAddress = viper.GetString(ListenAddressFlagKey) + if viper.GetString(flags.ListenAddressFlagKey) != "" { + listenAddress = viper.GetString(flags.ListenAddressFlagKey) } - if viper.GetString(MaxBackoffTimeSecondsFlagKey) != "" { - maximumBackoffTimeSeconds = viper.GetInt(MaxBackoffTimeSecondsFlagKey) + + setGlobalLogLevel(viper.GetString(flags.LogLevelFlagKey)) + + if viper.GetString(flags.MaxBackoffTimeSecondsFlagKey) != "" { + maximumBackoffTimeSeconds = viper.GetInt(flags.MaxBackoffTimeSecondsFlagKey) } amds := server.AlertManagerDiscordServer{ @@ -100,3 +100,26 @@ func Execute() { os.Exit(1) } } + +func setGlobalLogLevel(logLevel string) { + switch logLevel { + case "trace": + zerolog.SetGlobalLevel(zerolog.TraceLevel) + case "debug": + zerolog.SetGlobalLevel(zerolog.DebugLevel) + case "info": + zerolog.SetGlobalLevel(zerolog.InfoLevel) + case "warn": + zerolog.SetGlobalLevel(zerolog.WarnLevel) + case "error": + zerolog.SetGlobalLevel(zerolog.ErrorLevel) + case "fatal": + zerolog.SetGlobalLevel(zerolog.FatalLevel) + case "panic": + zerolog.SetGlobalLevel(zerolog.PanicLevel) + case "disabled": + zerolog.SetGlobalLevel(zerolog.Disabled) + default: + break + } +} diff --git a/deploy/helm/README.md b/deploy/helm/README.md index b174171..6b72d19 100644 --- a/deploy/helm/README.md +++ b/deploy/helm/README.md @@ -29,7 +29,7 @@ A Helm chart to deploy alertmanager-discord to Kubernetes | securityContext.readOnlyRootFilesystem | bool | `true` | | | securityContext.runAsNonRoot | bool | `true` | | | securityContext.runAsUser | int | `1000` | | -| server.configuration.key | string | `"config.yaml"` | | +| server.configuration.key | string | `"config.yaml"` | the key within the Kubernetes Secret. This key is expected to be a filename, as it will for the path for the configuration file when mounted to the container. | | server.configuration.name | string | `"discord-config"` | name of the Kubernetes Secret containing the configuration file, will be mounted to the container. Must be in the same namespace as this helm chart is deployed. | | service.port | int | `9094` | The port to which alertmanager should push alerts | | service.type | string | `"ClusterIP"` | | diff --git a/deploy/helm/values.yaml b/deploy/helm/values.yaml index eb715d3..c9ac44c 100644 --- a/deploy/helm/values.yaml +++ b/deploy/helm/values.yaml @@ -55,6 +55,7 @@ server: configuration: # -- name of the Kubernetes Secret containing the configuration file, will be mounted to the container. Must be in the same namespace as this helm chart is deployed. name: discord-config + # -- the key within the Kubernetes Secret. This key is expected to be a filename, as it will for the path for the configuration file when mounted to the container. key: config.yaml # within the config.yaml data, it should be yaml formatted with the key `discord_webhook_url`, and optionally keys `listen_address` & `max_backoff_time_seconds`. An example of the data expected can be found at ./test/test-config.yaml diff --git a/pkg/alertforwarder/alertforwarder.go b/pkg/alertforwarder/alertforwarder.go index a84dd1c..8bfda1b 100644 --- a/pkg/alertforwarder/alertforwarder.go +++ b/pkg/alertforwarder/alertforwarder.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "net/http" + "os" "time" "github.com/specklesystems/alertmanager-discord/pkg/alertmanager" @@ -13,6 +14,7 @@ import ( "github.com/specklesystems/alertmanager-discord/pkg/prometheus" "github.com/google/uuid" + "github.com/rs/zerolog" "github.com/rs/zerolog/log" ) @@ -24,9 +26,9 @@ type AlertForwarderHandler struct { af AlertForwarder } -func NewAlertForwarderHandler(client *http.Client, webhookURL string, maximumBackoffTimeSeconds time.Duration) *AlertForwarderHandler { +func NewAlertForwarderHandler(client *http.Client, webhookURL string, maximumBackoffElapsedTime time.Duration) *AlertForwarderHandler { return &AlertForwarderHandler{ - af: NewAlertForwarder(client, webhookURL, maximumBackoffTimeSeconds), + af: NewAlertForwarder(client, webhookURL, maximumBackoffElapsedTime), } } @@ -38,12 +40,20 @@ type AlertForwarder struct { client *discord.Client } -func NewAlertForwarder(client *http.Client, webhookURL string, maximumBackoffTimeSeconds time.Duration) AlertForwarder { +func NewAlertForwarder(client *http.Client, webhookURL string, maximumBackoffElapsedTime time.Duration) AlertForwarder { return AlertForwarder{ - client: discord.NewClient(client, webhookURL, maximumBackoffTimeSeconds), + client: discord.NewClient(client, webhookURL, maximumBackoffElapsedTime), } } +func (af *AlertForwarder) groupAlerts(amo *alertmanager.Out) map[string][]alertmanager.Alert { + groupedAlerts := make(map[string][]alertmanager.Alert) + for _, alert := range amo.Alerts { + groupedAlerts[alert.Status] = append(groupedAlerts[alert.Status], alert) + } + return groupedAlerts +} + func (af *AlertForwarder) sendWebhook(correlationId string, amo *alertmanager.Out, w http.ResponseWriter) { if len(amo.Alerts) < 1 { log.Debug(). @@ -53,31 +63,35 @@ func (af *AlertForwarder) sendWebhook(correlationId string, amo *alertmanager.Ou return } - groupedAlerts := make(map[string][]alertmanager.Alert) - for _, alert := range amo.Alerts { - groupedAlerts[alert.Status] = append(groupedAlerts[alert.Status], alert) + logger := zerolog.New(os.Stderr).With(). + Timestamp(). + Str(logging.FieldKeyCorrelationId, correlationId).Logger() + if amo.CommonLabels.Alertname != "" { + logger = logger.With().Str(logging.FieldKeyAlertName, amo.CommonLabels.Alertname).Logger() + } else if amo.GroupLabels.Alertname != "" { + logger = logger.With().Str(logging.FieldKeyAlertName, amo.GroupLabels.Alertname).Logger() } failedToPublishAtLeastOne := false - for status, alerts := range groupedAlerts { + for status, alerts := range af.groupAlerts(amo) { DO := TranslateAlertManagerToDiscord(status, amo, alerts) - log.Info(). + logger.Info(). Str(logging.FieldKeyEventType, logging.EventTypeRequestSending). Str(logging.FieldKeyCorrelationId, correlationId). Msg("Sending HTTP request to Discord.") res, err := af.client.PublishMessage(DO) if err != nil { - err = fmt.Errorf("Error encountered when publishing message to discord: %w", err) - log.Error(). + err = fmt.Errorf("failed to publish message to Discord: %w", err) + logger.Error(). Str(logging.FieldKeyCorrelationId, correlationId). Err(err). - Msg("Error when attempting to publish message to discord.") + Msg("Error when attempting to publish message to Discord.") failedToPublishAtLeastOne = true continue } - log.Info(). + logger.Info(). Str(logging.FieldKeyEventType, logging.EventTypeResponseReceived). Str(logging.FieldKeyCorrelationId, correlationId). Msg("HTTP response received from Discord") @@ -124,7 +138,7 @@ or https://prometheus.io/docs/alerting/latest/configuration/#webhook_config` Msg("Sending HTTP request to Discord.") res, err := af.client.PublishMessage(DO) if err != nil { - return nil, fmt.Errorf("Error encountered when publishing message to discord: %w", err) + return nil, fmt.Errorf("error encountered when publishing message to Discord: %w", err) } log.Info(). @@ -204,5 +218,4 @@ func (af *AlertForwarder) handleInvalidInput(correlationId string, b []byte, w h } w.WriteHeader(http.StatusBadRequest) - return } diff --git a/pkg/alertforwarder/checkwebhookurl.go b/pkg/alertforwarder/checkwebhookurl.go index 46bf593..9ed723e 100644 --- a/pkg/alertforwarder/checkwebhookurl.go +++ b/pkg/alertforwarder/checkwebhookurl.go @@ -17,13 +17,10 @@ func CheckWebhookURL(webhookURL string) (bool, *url.URL, error) { parsedUrl, err := url.Parse(webhookURL) if err != nil { - return false, &url.URL{}, fmt.Errorf("The Discord WebHook URL ('%s') cannot be parsed as a url: %w", webhookURL, err) + return false, &url.URL{}, fmt.Errorf("the Discord WebHook URL ('%s') cannot be parsed as a url: %w", webhookURL, err) } - host, _, err := net.SplitHostPort(parsedUrl.Host) - if err != nil { - // return false, parsedUrl, fmt.Errorf("The Discord WebHook URL ('%s') host ('%s') cannot be separated into domain/ip and port components: %w", webhookURL, parsedUrl.Host, err) - } + host, _, _ := net.SplitHostPort(parsedUrl.Host) if host == "" { host = parsedUrl.Host } @@ -37,7 +34,7 @@ func CheckWebhookURL(webhookURL string) (bool, *url.URL, error) { ok := re.Match([]byte(webhookURL)) if !ok { - return false, parsedUrl, fmt.Errorf("The Discord WebHook URL doesn't seem to be a valid Discord Webhook API url: '%s'", webhookURL) + return false, parsedUrl, fmt.Errorf("the Discord WebHook URL doesn't seem to be a valid Discord Webhook API url: '%s'", webhookURL) } return ok, parsedUrl, nil diff --git a/pkg/discord/metrics.go b/pkg/discord/metrics.go index 9c8ad0b..e0f78ca 100644 --- a/pkg/discord/metrics.go +++ b/pkg/discord/metrics.go @@ -8,17 +8,17 @@ import ( var ( RequestsToDiscordInFlight = promauto.NewGauge(prometheus.GaugeOpts{ Name: "discord_client_requests_in_flight", - Help: "The current number of http requests being sent by the discord client.", + Help: "The current number of http requests being sent by the Discord client.", }) RequestsToDiscordTotal = promauto.NewCounterVec(prometheus.CounterOpts{ Name: "discord_client_requests_total", - Help: "The total number of http requests sent by the discord client.", + Help: "The total number of http requests sent by the Discord client.", }, []string{"code", "method"}) RequestsToDiscordDuration = promauto.NewHistogramVec(prometheus.HistogramOpts{ Name: "discord_client_request_duration_seconds", - Help: "Duration of all http requests sent by the discord client.", + Help: "Duration of all http requests sent by the Discord client.", Buckets: prometheus.DefBuckets, }, []string{"code"}) ) diff --git a/pkg/flags/flags.go b/pkg/flags/flags.go index f44535e..ed86595 100644 --- a/pkg/flags/flags.go +++ b/pkg/flags/flags.go @@ -5,4 +5,5 @@ const ( DiscordWebhookUrlFlagKey = "discord_webhook_url" ListenAddressFlagKey = "listen_address" MaxBackoffTimeSecondsFlagKey = "max_backoff_time_seconds" + LogLevelFlagKey = "log_level" ) diff --git a/pkg/logging/fields.go b/pkg/logging/fields.go index 40b9890..9c72655 100644 --- a/pkg/logging/fields.go +++ b/pkg/logging/fields.go @@ -6,6 +6,7 @@ const ( FieldKeyHttpMethod = "method" FieldKeyHttpPath = "path" FieldKeyEventType = "event_type" + FieldKeyAlertName = "alert_name" FieldKeyCorrelationId = "correlation_id" FieldKeyStatusCode = "status_code" ) diff --git a/pkg/server/integration_test.go b/pkg/server/integration_test.go index ce61419..d49f3eb 100644 --- a/pkg/server/integration_test.go +++ b/pkg/server/integration_test.go @@ -22,11 +22,11 @@ const ( ) func Test_Serve_HappyPath(t *testing.T) { - // create a mock discord server to respond to our request + // create a mock Discord server to respond to our request receivedRequest := make(chan bool, 1) mockDiscordServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Discord mock server will always return with Status Code 200 OK - receivedRequest <- true // notify the channel that the discord server received the request + receivedRequest <- true // notify the channel that the Discord server received the request })) defer mockDiscordServer.Close() @@ -44,19 +44,23 @@ func Test_Serve_HappyPath(t *testing.T) { } res, err := client.Get(fmt.Sprintf("http://%s/liveness", serverListenAddress)) + assert.NoError(t, err) assert.NotNil(t, res, "response to GET '/liveness' should not be nil") assert.Equal(t, http.StatusOK, res.StatusCode, "GET liveness should return status code OK (200)") res, err = client.Get(fmt.Sprintf("http://%s/readiness", serverListenAddress)) + assert.NoError(t, err) assert.NotNil(t, res, "response to GET '/readiness' should not be nil") assert.Equal(t, http.StatusOK, res.StatusCode, "GET readiness should return status code OK (200)") res, err = client.Get(fmt.Sprintf("http://%s/favicon.ico", serverListenAddress)) + assert.NoError(t, err) assert.NotNil(t, res, "response to GET '/favicon.ico' should not be nil") assert.Equal(t, http.StatusOK, res.StatusCode, "GET favicon.ico should return status code OK (200)") res, err = client.Get(fmt.Sprintf("http://%s/metrics", serverListenAddress)) + assert.NoError(t, err) assert.NotNil(t, res, "response to GET '/metrics' should not be nil") assert.Equal(t, http.StatusOK, res.StatusCode, "GET favicon.ico should return status code OK (200)") - // assert mock discord server received expected json + // assert mock Discord server received expected json ao := alertmanager.Out{ Alerts: []alertmanager.Alert{ { @@ -68,6 +72,11 @@ func Test_Serve_HappyPath(t *testing.T) { }{ Summary: "a_common_annotation_summary", }, + GroupLabels: struct { + Alertname string `json:"alertname"` + }{ + Alertname: "testAlertName", + }, } aoJson, err := json.Marshal(ao) @@ -86,7 +95,7 @@ func Test_Serve_HappyPath(t *testing.T) { assert.NoError(t, err, "sending request to alertmanager-discord server.") assert.NotNil(t, res, "response to POST '/' should not be nil") assert.Equal(t, http.StatusOK, res.StatusCode, "sending valid alertmanager data should expect http response status code") - assert.True(t, <-receivedRequest, "Mock discord server should have received response") // will wait until the request is received + assert.True(t, <-receivedRequest, "Mock Discord server should have received response") // will wait until the request is received // TODO assert log lines were generated diff --git a/pkg/server/server.go b/pkg/server/server.go index c26bee6..a11e9df 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -15,7 +15,9 @@ import ( "github.com/rs/zerolog/log" ) -const DefaultListenAddress = "0.0.0.0:9094" +const ( + DefaultListenAddress = "0.0.0.0:9094" +) const ( FaviconPath = "/favicon.ico" LivenessPath = "/liveness"