diff --git a/config/config.go b/config/config.go index 80cc5783f9..20551354cb 100644 --- a/config/config.go +++ b/config/config.go @@ -33,6 +33,7 @@ import ( "github.com/prometheus/alertmanager/eventrecorder" "github.com/prometheus/alertmanager/matcher/compat" "github.com/prometheus/alertmanager/notify/discord" + "github.com/prometheus/alertmanager/notify/gotify" "github.com/prometheus/alertmanager/notify/incidentio" "github.com/prometheus/alertmanager/notify/jira" "github.com/prometheus/alertmanager/notify/mattermost" @@ -207,6 +208,9 @@ func resolveFilepaths(baseDir string, cfg *Config) { for _, cfg := range receiver.DiscordConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } + for _, cfg := range receiver.GotifyConfigs { + cfg.HTTPConfig.SetDirectory(baseDir) + } for _, cfg := range receiver.WebexConfigs { cfg.HTTPConfig.SetDirectory(baseDir) } @@ -569,6 +573,12 @@ func (c *Config) UnmarshalYAML(unmarshal func(any) error) error { return errors.New("no discord webhook URL or URLFile provided") } } + for _, gotifyCfg := range rcv.GotifyConfigs { + if gotifyCfg == nil { + return errors.New("missing gotify config") + } + gotifyCfg.HTTPConfig = cmp.Or(gotifyCfg.HTTPConfig, c.Global.HTTPConfig) + } for _, webex := range rcv.WebexConfigs { if webex == nil { return errors.New("missing webex config") @@ -966,6 +976,7 @@ type Receiver struct { Labels map[string]string `yaml:"labels,omitempty" json:"labels,omitempty"` DiscordConfigs []*discord.DiscordConfig `yaml:"discord_configs,omitempty" json:"discord_configs,omitempty"` + GotifyConfigs []*gotify.GotifyConfig `yaml:"gotify_configs,omitempty" json:"gotify_configs,omitempty"` EmailConfigs []*EmailConfig `yaml:"email_configs,omitempty" json:"email_configs,omitempty"` IncidentioConfigs []*incidentio.IncidentioConfig `yaml:"incidentio_configs,omitempty" json:"incidentio_configs,omitempty"` PagerdutyConfigs []*PagerdutyConfig `yaml:"pagerduty_configs,omitempty" json:"pagerduty_configs,omitempty"` diff --git a/config/receiver/receiver.go b/config/receiver/receiver.go index cdbacc7629..e16e8c1b58 100644 --- a/config/receiver/receiver.go +++ b/config/receiver/receiver.go @@ -24,6 +24,7 @@ import ( "github.com/prometheus/alertmanager/notify" "github.com/prometheus/alertmanager/notify/discord" "github.com/prometheus/alertmanager/notify/email" + "github.com/prometheus/alertmanager/notify/gotify" "github.com/prometheus/alertmanager/notify/incidentio" "github.com/prometheus/alertmanager/notify/jira" "github.com/prometheus/alertmanager/notify/mattermost" @@ -96,6 +97,9 @@ func BuildReceiverIntegrations(nc config.Receiver, tmpl *template.Template, logg for i, c := range nc.DiscordConfigs { add("discord", i, c, func(l *slog.Logger) (notify.Notifier, error) { return discord.New(c, tmpl, l, httpOpts...) }) } + for i, c := range nc.GotifyConfigs { + add("gotify", i, c, func(l *slog.Logger) (notify.Notifier, error) { return gotify.New(c, tmpl, l, httpOpts...) }) + } for i, c := range nc.WebexConfigs { add("webex", i, c, func(l *slog.Logger) (notify.Notifier, error) { return webex.New(c, tmpl, l, httpOpts...) }) } diff --git a/docs/configuration.md b/docs/configuration.md index e202385c39..411b551f6d 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -830,6 +830,8 @@ labels: # Configurations for several notification integrations. discord_configs: [ - , ... ] +gotify_configs: + [ - , ... ] email_configs: [ - , ... ] mattermost_configs: @@ -1613,6 +1615,47 @@ token_file: [ http_config: | default = global.http_config ] ``` +### `` + +Gotify notifications are sent via the [Gotify message API](https://gotify.net/docs/pushmsg). + +```yaml +# Whether to notify about resolved alerts. +[ send_resolved: | default = true ] + +# Gotify server URL to send the request to. +# Typically: https://gotify.example.com/message +# url and url_file are mutually exclusive. +url: +url_file: + +# The Gotify application token. +# token and token_file are mutually exclusive. +token: +token_file: + +# Notification title. +[ title: | default = '{{ template "gotify.default.title" . }}' ] + +# Notification message. +[ message: | default = '{{ template "gotify.default.message" . }}' ] + +# Priority. +[ priority: | default = '{{ if eq .Status "firing" }}5{{ else }}0{{ end }}' ] + +# Message content type. Used to populate Gotify message extras. +[ content_type: | default = 'text/plain' ] + +# The HTTP client's configuration. +[ http_config: | default = global.http_config ] + +# The maximum time to wait for a gotify request to complete, before failing the +# request and allowing it to be retried. The default value of 0s indicates that +# no timeout should be applied. +# NOTE: This will have no effect if set higher than the group_interval. +[ timeout: | default = 0s ] +``` + ### `` Rocketchat notifications are sent via the [Rocketchat REST API](https://developer.rocket.chat/reference/api/rest-api/endpoints/messaging/chat-endpoints/postmessage). diff --git a/docs/notification_examples.md b/docs/notification_examples.md index 7e5dd23b6c..7b95a79ed6 100644 --- a/docs/notification_examples.md +++ b/docs/notification_examples.md @@ -6,6 +6,25 @@ sort_rank: 8 The following are all different examples of alerts and corresponding Alertmanager configuration file setups (alertmanager.yml). Each use the [Go templating](http://golang.org/pkg/text/template/) system. +## Sending notifications to Gotify + +In this example we configure a Gotify receiver using an application token. + +``` +route: + receiver: 'gotify-notifications' + +receivers: +- name: 'gotify-notifications' + gotify_configs: + - url: 'http://localhost:8080/message' + token: '' + title: '{{ template "gotify.default.title" . }}' + message: '{{ template "gotify.default.message" . }}' + priority: '{{ if eq .Status "firing" }}5{{ else }}0{{ end }}' + content_type: 'text/plain' +``` + ## Customizing Slack notifications In this example we've customised our Slack notification to send a URL to our organisation's wiki on how to deal with the particular alert that's been sent. diff --git a/notify/gotify/config.go b/notify/gotify/config.go new file mode 100644 index 0000000000..20fd24568e --- /dev/null +++ b/notify/gotify/config.go @@ -0,0 +1,76 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gotify + +import ( + "errors" + "time" + + amcommoncfg "github.com/prometheus/alertmanager/config/common" + + commoncfg "github.com/prometheus/common/config" +) + +var defaultGotifyConfig = GotifyConfig{ + NotifierConfig: amcommoncfg.NotifierConfig{ + VSendResolved: true, + }, + Title: `{{ template "gotify.default.title" . }}`, + Message: `{{ template "gotify.default.message" . }}`, + Priority: `{{ if eq .Status "firing" }}5{{ else }}0{{ end }}`, + ContentType: "text/plain", +} + +type GotifyConfig struct { + amcommoncfg.NotifierConfig `yaml:",inline" json:",inline"` + HTTPConfig *commoncfg.HTTPClientConfig `yaml:"http_config,omitempty" json:"http_config,omitempty"` + URL *amcommoncfg.URL `yaml:"url,omitempty" json:"url,omitempty"` + URLFile string `yaml:"url_file,omitempty" json:"url_file,omitempty"` + + Token commoncfg.Secret `yaml:"token,omitempty" json:"token,omitempty"` + TokenFile string `yaml:"token_file,omitempty" json:"token_file,omitempty"` + + Title string `yaml:"title,omitempty" json:"title,omitempty"` + Message string `yaml:"message,omitempty" json:"message,omitempty"` + Priority string `yaml:"priority,omitempty" json:"priority,omitempty"` + ContentType string `yaml:"content_type,omitempty" json:"content_type,omitempty"` + + Timeout time.Duration `yaml:"timeout" json:"timeout"` +} + +func (c *GotifyConfig) UnmarshalYAML(unmarshal func(any) error) error { + *c = defaultGotifyConfig + type plain GotifyConfig + if err := unmarshal((*plain)(c)); err != nil { + return err + } + + if c.URL == nil && c.URLFile == "" { + return errors.New("one of url or url_file must be configured") + } + if c.URL != nil && c.URLFile != "" { + return errors.New("at most one of url & url_file must be configured") + } + if c.Token == "" && c.TokenFile == "" { + return errors.New("one of token or token_file must be configured") + } + if c.Token != "" && c.TokenFile != "" { + return errors.New("at most one of token & token_file must be configured") + } + if c.ContentType == "" { + c.ContentType = "text/plain" + } + + return nil +} diff --git a/notify/gotify/config_test.go b/notify/gotify/config_test.go new file mode 100644 index 0000000000..7e07c991ef --- /dev/null +++ b/notify/gotify/config_test.go @@ -0,0 +1,98 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gotify + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestGotifyConfig_UnmarshalYAML(t *testing.T) { + test := []struct { + name string + in string + expected error + }{ + { + name: "with url and token - successful run", + in: ` +url: http://localhost:3000 +token: 00000000-0000-0000-0000-0000000000001 +`, + }, { + name: "with url_file and token_file - successful run", + in: ` +url_file: /path/to/file +token_file: /path/to/token +`, + }, { + name: "with url and token_file - successful run", + in: ` +url: http://localhost:3000 +token_file: /path/to/token +`, + }, { + name: "with url_file and token - successful run", + in: ` +url_file: /path/to/file +token: 00000000-0000-0000-0000-0000000000001 +`, + }, { + name: "missing url and url_file, token provided - expected error missing url or url_file", + in: ` +token: 00000000-0000-0000-0000-0000000000001 +`, + expected: errors.New("one of url or url_file must be configured"), + }, { + name: "missing token and token_file, url provided - expected error missing token or token_file", + in: ` +url: http://localhost:3000 +`, + expected: errors.New("one of token or token_file must be configured"), + }, { + name: "url and url_file provided - expected error at most one of url & url_file", + in: ` +url: http://localhost:3000 +url_file: /path/to/file +`, + expected: errors.New("at most one of url & url_file must be configured"), + }, { + name: "token and token_file provided - expected error at most one of token & token_file", + in: ` +url: http://localhost:3000 +token: 00000000-0000-0000-0000-0000000000001 +token_file: /path/to/token +`, + expected: errors.New("at most one of token & token_file must be configured"), + }, { + name: "empty content type - should default to text/plain", + in: ` +url: http://localhost:3000 +token: 00000000-0000-0000-0000-0000000000001 +content_type: "" +`, + }, + } + + for _, tt := range test { + t.Run(tt.name, func(t *testing.T) { + var cfg GotifyConfig + err := yaml.UnmarshalStrict([]byte(tt.in), &cfg) + require.Equal(t, tt.expected, err) + }) + } +} diff --git a/notify/gotify/gotify.go b/notify/gotify/gotify.go new file mode 100644 index 0000000000..2cbad4c25d --- /dev/null +++ b/notify/gotify/gotify.go @@ -0,0 +1,170 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gotify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "strconv" + "strings" + + commoncfg "github.com/prometheus/common/config" + + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/template" + "github.com/prometheus/alertmanager/types" +) + +type Notifier struct { + conf *GotifyConfig + tmpl *template.Template + logger *slog.Logger + client *http.Client + retrier *notify.Retrier +} + +type gotifyRoundTripper struct { + wrapped http.RoundTripper + token string +} + +func (t *gotifyRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Set("X-Gotify-Key", t.token) + return t.wrapped.RoundTrip(req) +} + +func New(c *GotifyConfig, t *template.Template, l *slog.Logger, httpOpts ...commoncfg.HTTPClientOption) (*Notifier, error) { + client, err := notify.NewClientWithTracing(*c.HTTPConfig, "gotify", httpOpts...) + if err != nil { + return nil, err + } + + var token string + if c.Token != "" { + token = string(c.Token) + } else { + b, err := os.ReadFile(c.TokenFile) + if err != nil { + return nil, fmt.Errorf("read token_file: %w", err) + } + token = strings.TrimSpace(string(b)) + } + if token == "" { + return nil, fmt.Errorf("gotify token is empty") + } + + client.Transport = &gotifyRoundTripper{wrapped: client.Transport, token: token} + + return &Notifier{ + conf: c, + tmpl: t, + logger: l, + client: client, + retrier: ¬ify.Retrier{}, + }, nil +} + +type messageExtrasClientDisplay struct { + ContentType string `json:"contentType,omitempty"` +} + +type messageExtras struct { + ClientDisplay *messageExtrasClientDisplay `json:"client::display,omitempty"` +} + +type messageRequest struct { + Title string `json:"title,omitempty"` + Message string `json:"message"` + Priority int `json:"priority,omitempty"` + Extras *messageExtras `json:"extras,omitempty"` +} + +func (n *Notifier) Notify(ctx context.Context, as ...*types.Alert) (bool, error) { + key, err := notify.ExtractGroupKey(ctx) + if err != nil { + return false, err + } + logger := n.logger.With("group_key", key) + logger.Debug("extracted group key") + + data := notify.GetTemplateData(ctx, n.tmpl, as, logger) + var tmplErr error + tmplText := notify.TmplText(n.tmpl, data, &tmplErr) + + var url string + if n.conf.URL != nil { + url = n.conf.URL.String() + } else { + b, err := os.ReadFile(n.conf.URLFile) + if err != nil { + return false, fmt.Errorf("read url_file: %w", err) + } + url = strings.TrimSpace(string(b)) + } + + priority, err := strconv.Atoi(strings.TrimSpace(tmplText(n.conf.Priority))) + if err != nil { + if tmplErr != nil { + return false, tmplErr + } + return false, fmt.Errorf("parse priority: %w", err) + } + + req := messageRequest{ + Title: strings.TrimSpace(tmplText(n.conf.Title)), + Message: strings.TrimSpace(tmplText(n.conf.Message)), + Priority: priority, + } + if req.Message == "" { + req.Message = "(no details)" + } + if n.conf.ContentType != "" && n.conf.ContentType != "text/plain" { + req.Extras = &messageExtras{ClientDisplay: &messageExtrasClientDisplay{ContentType: n.conf.ContentType}} + } + + if tmplErr != nil { + return false, tmplErr + } + + if n.conf.Timeout > 0 { + postCtx, cancel := context.WithTimeoutCause(ctx, n.conf.Timeout, fmt.Errorf("configured gotify timeout reached (%s)", n.conf.Timeout)) + defer cancel() + ctx = postCtx + } + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(&req); err != nil { + return false, err + } + + resp, err := notify.PostJSON(ctx, n.client, url, &buf) + if err != nil { + if ctx.Err() != nil { + err = fmt.Errorf("%w: %w", err, context.Cause(ctx)) + } + return true, notify.RedactURL(err) + } + defer notify.Drain(resp) + + shouldRetry, err := n.retrier.Check(resp.StatusCode, resp.Body) + if err != nil { + return shouldRetry, notify.NewErrorWithReason(notify.GetFailureReasonFromStatusCode(resp.StatusCode), err) + } + return shouldRetry, nil +} diff --git a/notify/gotify/gotify_test.go b/notify/gotify/gotify_test.go new file mode 100644 index 0000000000..282ebde431 --- /dev/null +++ b/notify/gotify/gotify_test.go @@ -0,0 +1,140 @@ +// Copyright The Prometheus Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package gotify + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "os" + "testing" + "time" + + commoncfg "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + "github.com/prometheus/common/promslog" + "github.com/stretchr/testify/require" + + amcommoncfg "github.com/prometheus/alertmanager/config/common" + "github.com/prometheus/alertmanager/notify" + "github.com/prometheus/alertmanager/notify/test" + "github.com/prometheus/alertmanager/types" +) + +func TestGotify_Notify(t *testing.T) { + var gotHeader string + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotHeader = r.Header.Get("X-Gotify-Key") + require.Equal(t, "application/json", r.Header.Get("Content-Type")) + dec := json.NewDecoder(r.Body) + require.NoError(t, dec.Decode(&gotBody)) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + cfg := &GotifyConfig{ + URL: &amcommoncfg.URL{URL: u}, + Token: commoncfg.Secret("token"), + HTTPConfig: &commoncfg.HTTPClientConfig{}, + Title: "t", + Message: "m", + Priority: "2", + } + + n, err := New(cfg, test.CreateTmpl(t), promslog.NewNopLogger()) + require.NoError(t, err) + + ctx := notify.WithGroupKey(context.Background(), "1") + _, err = n.Notify(ctx, &types.Alert{Alert: model.Alert{StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour)}}) + require.NoError(t, err) + + require.Equal(t, "token", gotHeader) + require.Equal(t, "t", gotBody["title"]) + require.Equal(t, "m", gotBody["message"]) + require.EqualValues(t, float64(2), gotBody["priority"]) + _, ok := gotBody["extras"] + require.False(t, ok) +} + +func TestGotify_Notify_MarkdownExtras(t *testing.T) { + var gotBody map[string]any + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + dec := json.NewDecoder(r.Body) + require.NoError(t, dec.Decode(&gotBody)) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + u, err := url.Parse(srv.URL) + require.NoError(t, err) + + cfg := &GotifyConfig{ + URL: &amcommoncfg.URL{URL: u}, + Token: commoncfg.Secret("token"), + HTTPConfig: &commoncfg.HTTPClientConfig{}, + Title: "t", + Message: "m", + Priority: "2", + ContentType: "text/markdown", + } + + n, err := New(cfg, test.CreateTmpl(t), promslog.NewNopLogger()) + require.NoError(t, err) + + ctx := notify.WithGroupKey(context.Background(), "1") + _, err = n.Notify(ctx, &types.Alert{Alert: model.Alert{StartsAt: time.Now(), EndsAt: time.Now().Add(time.Hour)}}) + require.NoError(t, err) + + extras, ok := gotBody["extras"].(map[string]any) + require.True(t, ok) + clientDisplay, ok := extras["client::display"].(map[string]any) + require.True(t, ok) + require.Equal(t, "text/markdown", clientDisplay["contentType"]) +} + +func TestGotifyReadingURLAndTokenFromFiles(t *testing.T) { + ctx, u, fn := test.GetContextWithCancelingURL() + defer fn() + + urlFile, err := os.CreateTemp(t.TempDir(), "gotify_url") + require.NoError(t, err) + _, err = urlFile.WriteString(u.String() + "\n") + require.NoError(t, err) + + tokenFile, err := os.CreateTemp(t.TempDir(), "gotify_token") + require.NoError(t, err) + _, err = tokenFile.WriteString("secret\n") + require.NoError(t, err) + + n, err := New(&GotifyConfig{ + URLFile: urlFile.Name(), + TokenFile: tokenFile.Name(), + HTTPConfig: &commoncfg.HTTPClientConfig{}, + Title: "t", + Message: "m", + Priority: "2", + ContentType: "text/plain", + }, test.CreateTmpl(t), promslog.NewNopLogger()) + require.NoError(t, err) + + test.AssertNotifyLeaksNoSecret(ctx, t, n, "secret") +} diff --git a/template/default.tmpl b/template/default.tmpl index fa7828f6f2..ec65006c1a 100644 --- a/template/default.tmpl +++ b/template/default.tmpl @@ -101,6 +101,18 @@ Alerts Resolved: {{ end }} {{ define "pushover.default.url" }}{{ template "__alertmanagerURL" . }}{{ end }} +{{ define "gotify.default.title" }}{{ template "__subject" . }}{{ end }} +{{ define "gotify.default.message" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }} +{{ if gt (len .Alerts.Firing) 0 }} +Alerts Firing: +{{ template "__text_alert_list" .Alerts.Firing }} +{{ end }} +{{ if gt (len .Alerts.Resolved) 0 }} +Alerts Resolved: +{{ template "__text_alert_list" .Alerts.Resolved }} +{{ end }} +{{ end }} + {{ define "sns.default.subject" }}{{ template "__subject" . }}{{ end }} {{ define "sns.default.message" }}{{ .CommonAnnotations.SortedPairs.Values | join " " }} {{ if gt (len .Alerts.Firing) 0 }}