From 778019590d4ed5289d6ec432e77b36ec98437af5 Mon Sep 17 00:00:00 2001 From: Stephen Date: Sat, 30 Nov 2024 19:09:20 -0800 Subject: [PATCH] feat(alerting): Add overrides for Ntfy provider (#918) * Add overrides to Ntfy alert provider * Update alerting/provider/ntfy/ntfy.go --------- Co-authored-by: TwiN --- README.md | 51 +++++++++---- alerting/provider/ntfy/ntfy.go | 109 +++++++++++++++++++++++++--- alerting/provider/ntfy/ntfy_test.go | 70 +++++++++++++++++- 3 files changed, 206 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 50dc8165..677e6ddd 100644 --- a/README.md +++ b/README.md @@ -240,7 +240,7 @@ If you want to test it locally, see [Docker](#docker). | `ui.buttons[].link` | Link to open when the button is clicked. | Required `""` | | `maintenance` | [Maintenance configuration](#maintenance). | `{}` | -If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`. +If you want more verbose logging, you may set the `GATUS_LOG_LEVEL` environment variable to `DEBUG`. Conversely, if you want less verbose logging, you can set the aforementioned environment variable to `WARN`, `ERROR` or `FATAL`. The default value for `GATUS_LOG_LEVEL` is `INFO`. @@ -987,18 +987,26 @@ endpoints: #### Configuring Ntfy alerts -| Parameter | Description | Default | -|:---------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------| -| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` | -| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` | -| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` | -| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` | -| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` | -| `alerting.ntfy.click` | Website opened when notification is clicked | `""` | -| `alerting.ntfy.priority` | The priority of the alert | `3` | -| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` | -| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` | -| `alerting.ntfy.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| Parameter | Description | Default | +|:-------------------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------|:------------------| +| `alerting.ntfy` | Configuration for alerts of type `ntfy` | `{}` | +| `alerting.ntfy.topic` | Topic at which the alert will be sent | Required `""` | +| `alerting.ntfy.url` | The URL of the target server | `https://ntfy.sh` | +| `alerting.ntfy.token` | [Access token](https://docs.ntfy.sh/publish/#access-tokens) for restricted topics | `""` | +| `alerting.ntfy.email` | E-mail address for additional e-mail notifications | `""` | +| `alerting.ntfy.click` | Website opened when notification is clicked | `""` | +| `alerting.ntfy.priority` | The priority of the alert | `3` | +| `alerting.ntfy.disable-firebase` | Whether message push delivery via firebase should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#disable-firebase) | `false` | +| `alerting.ntfy.disable-cache` | Whether server side message caching should be disabled. [ntfy.sh defaults to enabled](https://docs.ntfy.sh/publish/#message-caching) | `false` | +| `alerting.ntfy.default-alert` | Default alert configuration.
See [Setting a default alert](#setting-a-default-alert) | N/A | +| `alerting.ntfy.overrides` | List of overrides that may be prioritized over the default configuration | `[]` | +| `alerting.ntfy.overrides[].group` | Endpoint group for which the configuration will be overridden by this configuration | `""` | +| `alerting.ntfy.overrides[].topic` | Topic at which the alert will be sent | `""` | +| `alerting.ntfy.overrides[].url` | The URL of the target server | `""` | +| `alerting.ntfy.overrides[].priority` | The priority of the alert | `0` | +| `alerting.ntfy.overrides[].token` | Access token for restricted topics | `""` | +| `alerting.ntfy.overrides[].email` | E-mail address for additional e-mail notifications | `""` | +| `alerting.ntfy.overrides[].click` | Website opened when notification is clicked | `""` | [ntfy](https://github.com/binwiederhier/ntfy) is an amazing project that allows you to subscribe to desktop and mobile notifications, making it an awesome addition to Gatus. @@ -1013,6 +1021,13 @@ alerting: default-alert: failure-threshold: 3 send-on-resolved: true + # You can also add group-specific to keys, which will + # override the to key above for the specified groups + overrides: + - group: "other" + topic: "gatus-other-test-topic" + priority: 4 + click: "https://example.com" endpoints: - name: website @@ -1024,6 +1039,16 @@ endpoints: - "[RESPONSE_TIME] < 300" alerts: - type: ntfy + - name: other example + group: other + interval: 30m + url: "https://example.com" + conditions: + - "[STATUS] == 200" + - "[BODY].status == UP" + alerts: + - type: ntfy + description: example ``` diff --git a/alerting/provider/ntfy/ntfy.go b/alerting/provider/ntfy/ntfy.go index 784caf39..6deba1aa 100644 --- a/alerting/provider/ntfy/ntfy.go +++ b/alerting/provider/ntfy/ntfy.go @@ -17,6 +17,7 @@ import ( const ( DefaultURL = "https://ntfy.sh" DefaultPriority = 3 + TokenPrefix = "tk_" ) // AlertProvider is the configuration necessary for sending an alert using Slack @@ -32,6 +33,20 @@ type AlertProvider struct { // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` + + // Overrides is a list of Override that may be prioritized over the default configuration + Overrides []Override `yaml:"overrides,omitempty"` +} + +// Override is a case under which the default integration is overridden +type Override struct { + Group string `yaml:"group"` + Topic string `yaml:"topic"` + URL string `yaml:"url"` + Priority int `yaml:"priority"` + Token string `yaml:"token"` + Email string `yaml:"email"` + Click string `yaml:"click"` } // IsValid returns whether the provider's configuration is valid @@ -44,21 +59,42 @@ func (provider *AlertProvider) IsValid() bool { } isTokenValid := true if len(provider.Token) > 0 { - isTokenValid = strings.HasPrefix(provider.Token, "tk_") + isTokenValid = strings.HasPrefix(provider.Token, TokenPrefix) } + registeredGroups := make(map[string]bool) + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if len(override.Group) == 0 { + return false + } + if _, ok := registeredGroups[override.Group]; ok { + return false + } + if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) { + return false + } + if override.Priority < 0 || override.Priority >= 6 { + return false + } + registeredGroups[override.Group] = true + } + } + return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid } // Send an alert using the provider func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { - buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) - request, err := http.NewRequest(http.MethodPost, provider.URL, buffer) + override := provider.getGroupOverride(ep.Group) + buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved, override)) + url := provider.getURL(override) + request, err := http.NewRequest(http.MethodPost, url, buffer) if err != nil { return err } request.Header.Set("Content-Type", "application/json") - if len(provider.Token) > 0 { - request.Header.Set("Authorization", "Bearer "+provider.Token) + if token := provider.getToken(override); len(token) > 0 { + request.Header.Set("Authorization", "Bearer "+token) } if provider.DisableFirebase { request.Header.Set("Firebase", "no") @@ -89,7 +125,7 @@ type Body struct { } // buildRequestBody builds the request body for the provider -func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { +func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, override *Override) []byte { var message, formattedConditionResults, tag string if resolved { tag = "white_check_mark" @@ -112,13 +148,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al } message += formattedConditionResults body, _ := json.Marshal(Body{ - Topic: provider.Topic, + Topic: provider.getTopic(override), Title: "Gatus: " + ep.DisplayName(), Message: message, Tags: []string{tag}, - Priority: provider.Priority, - Email: provider.Email, - Click: provider.Click, + Priority: provider.getPriority(override), + Email: provider.getEmail(override), + Click: provider.getClick(override), }) return body } @@ -127,3 +163,56 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { return provider.DefaultAlert } + +func (provider *AlertProvider) getGroupOverride(group string) *Override { + if provider.Overrides != nil { + for _, override := range provider.Overrides { + if group == override.Group { + return &override + } + } + } + return nil +} + +func (provider *AlertProvider) getTopic(override *Override) string { + if override != nil && len(override.Topic) > 0 { + return override.Topic + } + return provider.Topic +} + +func (provider *AlertProvider) getURL(override *Override) string { + if override != nil && len(override.URL) > 0 { + return override.URL + } + return provider.URL +} + +func (provider *AlertProvider) getPriority(override *Override) int { + if override != nil && override.Priority > 0 { + return override.Priority + } + return provider.Priority +} + +func (provider *AlertProvider) getToken(override *Override) string { + if override != nil && len(override.Token) > 0 { + return override.Token + } + return provider.Token +} + +func (provider *AlertProvider) getEmail(override *Override) string { + if override != nil && len(override.Email) > 0 { + return override.Email + } + return provider.Email +} + +func (provider *AlertProvider) getClick(override *Override) string { + if override != nil && len(override.Click) > 0 { + return override.Click + } + return provider.Click +} diff --git a/alerting/provider/ntfy/ntfy_test.go b/alerting/provider/ntfy/ntfy_test.go index 452533f4..304240d8 100644 --- a/alerting/provider/ntfy/ntfy_test.go +++ b/alerting/provider/ntfy/ntfy_test.go @@ -57,6 +57,31 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) { provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"}, expected: true, }, + { + name: "invalid-override-token", + provider: AlertProvider{Topic: "example", Overrides: []Override{Override{Group: "g", Token: "xx_faketoken"}}}, + expected: false, + }, + { + name: "invalid-override-priority", + provider: AlertProvider{Topic: "example", Overrides: []Override{Override{Group: "g", Priority: 8}}}, + expected: false, + }, + { + name: "no-override-group-name", + provider: AlertProvider{Topic: "example", Overrides: []Override{Override{}}}, + expected: false, + }, + { + name: "duplicate-override-group-names", + provider: AlertProvider{Topic: "example", Overrides: []Override{Override{Group: "g"}, Override{Group: "g"}}}, + expected: false, + }, + { + name: "valid-override", + provider: AlertProvider{Topic: "example", Overrides: []Override{Override{Group: "g1", Priority: 4, Click: "https://example.com"}, Override{Group: "g2", Topic: "Example", Token: "tk_faketoken"}}}, + expected: true, + }, } for _, scenario := range scenarios { t.Run(scenario.name, func(t *testing.T) { @@ -75,6 +100,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Provider AlertProvider Alert alert.Alert Resolved bool + Override *Override ExpectedBody string }{ { @@ -82,6 +108,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, + Override: nil, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`, }, { @@ -89,6 +116,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: true, + Override: nil, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`, }, { @@ -96,6 +124,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, + Override: nil, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, }, { @@ -103,8 +132,17 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: true, + Override: nil, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`, }, + { + Name: "override", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, + Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Override: &Override{Group: "g", Topic: "override-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}, + ExpectedBody: `{"topic":"override-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`, + }, } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { @@ -118,6 +156,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) { }, }, scenario.Resolved, + scenario.Override, ) if string(body) != scenario.ExpectedBody { t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) @@ -137,6 +176,7 @@ func TestAlertProvider_Send(t *testing.T) { Provider AlertProvider Alert alert.Alert Resolved bool + Group string ExpectedBody string ExpectedHeaders map[string]string }{ @@ -145,16 +185,30 @@ func TestAlertProvider_Send(t *testing.T) { Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, + Group: "", ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, ExpectedHeaders: map[string]string{ "Content-Type": "application/json", }, }, + { + Name: "token", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Group: "", + ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, + ExpectedHeaders: map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer tk_mytoken", + }, + }, { Name: "no firebase", Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, + Group: "", ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, ExpectedHeaders: map[string]string{ "Content-Type": "application/json", @@ -166,6 +220,7 @@ func TestAlertProvider_Send(t *testing.T) { Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, + Group: "", ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, ExpectedHeaders: map[string]string{ "Content-Type": "application/json", @@ -177,6 +232,7 @@ func TestAlertProvider_Send(t *testing.T) { Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Resolved: false, + Group: "", ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, ExpectedHeaders: map[string]string{ "Content-Type": "application/json", @@ -184,6 +240,18 @@ func TestAlertProvider_Send(t *testing.T) { "Cache": "no", }, }, + { + Name: "overrides", + Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken", Overrides: []Override{Override{Group: "other-group", URL: "https://example.com", Token: "tk_othertoken"}, Override{Group: "test-group", Token: "tk_test_token"}}}, + Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, + Resolved: false, + Group: "test-group", + ExpectedBody: `{"topic":"example","title":"Gatus: test-group/endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`, + ExpectedHeaders: map[string]string{ + "Content-Type": "application/json", + "Authorization": "Bearer tk_test_token", + }, + }, } for _, scenario := range scenarios { t.Run(scenario.Name, func(t *testing.T) { @@ -207,7 +275,7 @@ func TestAlertProvider_Send(t *testing.T) { scenario.Provider.URL = server.URL err := scenario.Provider.Send( - &endpoint.Endpoint{Name: "endpoint-name"}, + &endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group}, &scenario.Alert, &endpoint.Result{ ConditionResults: []*endpoint.ConditionResult{