Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(alerting): Add gotify provider #605

Merged
merged 7 commits into from
Nov 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .github/assets/gotify-alerts.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ Have any feedback or questions? [Create a discussion](https://github.com/TwiN/ga
- [Configuring GitHub alerts](#configuring-github-alerts)
- [Configuring GitLab alerts](#configuring-gitlab-alerts)
- [Configuring Google Chat alerts](#configuring-google-chat-alerts)
- [Configuring Gotify alerts](#configuring-gotify-alerts)
- [Configuring Matrix alerts](#configuring-matrix-alerts)
- [Configuring Mattermost alerts](#configuring-mattermost-alerts)
- [Configuring Messagebird alerts](#configuring-messagebird-alerts)
Expand Down Expand Up @@ -423,6 +424,7 @@ ignored.
| `alerting.github` | Configuration for alerts of type `github`. <br />See [Configuring GitHub alerts](#configuring-github-alerts). | `{}` |
| `alerting.gitlab` | Configuration for alerts of type `gitlab`. <br />See [Configuring GitLab alerts](#configuring-gitlab-alerts). | `{}` |
| `alerting.googlechat` | Configuration for alerts of type `googlechat`. <br />See [Configuring Google Chat alerts](#configuring-google-chat-alerts). | `{}` |
| `alerting.gotify` | Configuration for alerts of type `gotify`. <br />See [Configuring Gotify alerts](#configuring-gotify-alerts). | `{}` |
| `alerting.matrix` | Configuration for alerts of type `matrix`. <br />See [Configuring Matrix alerts](#configuring-matrix-alerts). | `{}` |
| `alerting.mattermost` | Configuration for alerts of type `mattermost`. <br />See [Configuring Mattermost alerts](#configuring-mattermost-alerts). | `{}` |
| `alerting.messagebird` | Configuration for alerts of type `messagebird`. <br />See [Configuring Messagebird alerts](#configuring-messagebird-alerts). | `{}` |
Expand Down Expand Up @@ -638,6 +640,41 @@ endpoints:
```


#### Configuring Gotify alerts
| Parameter | Description | Default |
|:----------------------------------------------|:--------------------------------------------------------------------------------------------|:-----------------------|
| `alerting.gotify` | Configuration for alerts of type `gotify` | `{}` |
| `alerting.gotify.server-url` | Gotify server URL | Required `""` |
| `alerting.gotify.token` | Token that is used for authentication. | Required `""` |
| `alerting.gotify.priority` | Priority of the alert according to Gotify standarts. | `5` |
| `alerting.gotify.default-alert` | Default alert configuration. <br />See [Setting a default alert](#setting-a-default-alert). | N/A |
| `alerting.gotify.title` | Title of the notification | `"Gatus: <endpoint>"` |

```yaml
alerting:
gotify:
server-url: "https://gotify.example"
token: "**************"

endpoints:
- name: website
url: "https://twin.sh/health"
interval: 5m
conditions:
- "[STATUS] == 200"
- "[BODY].status == UP"
- "[RESPONSE_TIME] < 300"
alerts:
- type: gotify
description: "healthcheck failed"
send-on-resolved: true
```

Here's an example of what the notifications look like:

![Gotify notifications](.github/assets/gotify-alerts.png)


#### Configuring Matrix alerts
| Parameter | Description | Default |
|:-----------------------------------------|:-------------------------------------------------------------------------------------------|:-----------------------------------|
Expand Down
3 changes: 3 additions & 0 deletions alerting/alert/type.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ const (
// TypeGoogleChat is the Type for the googlechat alerting provider
TypeGoogleChat Type = "googlechat"

// TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify"

// TypeMatrix is the Type for the matrix alerting provider
TypeMatrix Type = "matrix"

Expand Down
4 changes: 4 additions & 0 deletions alerting/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat"
"github.com/TwiN/gatus/v5/alerting/provider/gotify"
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
Expand Down Expand Up @@ -50,6 +51,9 @@ type Config struct {
// GoogleChat is the configuration for the googlechat alerting provider
GoogleChat *googlechat.AlertProvider `yaml:"googlechat,omitempty"`

// Gotify is the configuration for the gotify alerting provider
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`

// Matrix is the configuration for the matrix alerting provider
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`

Expand Down
105 changes: 105 additions & 0 deletions alerting/provider/gotify/gotify.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package gotify

import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"

"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/core"
)

const DefaultPriority = 5

// AlertProvider is the configuration necessary for sending an alert using Gotify
type AlertProvider struct {
// ServerURL is the URL of the Gotify server
ServerURL string `yaml:"server-url"`

// Token is the token to use when sending a message to the Gotify server
Token string `yaml:"token"`

// Priority is the priority of the message
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority

// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`

// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
}

// IsValid returns whether the provider's configuration is valid
func (provider *AlertProvider) IsValid() bool {
if provider.Priority == 0 {
provider.Priority = DefaultPriority
}
return len(provider.ServerURL) > 0 && len(provider.Token) > 0
}

// Send an alert using the provider
func (provider *AlertProvider) Send(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(endpoint, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("failed to send alert to Gotify: %s", string(body))
}
return nil
}

type Body struct {
Message string `json:"message"`
Title string `json:"title"`
Priority int `json:"priority"`
}

// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(endpoint *core.Endpoint, alert *alert.Alert, result *core.Result, resolved bool) []byte {
var message, results string
if resolved {
message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", endpoint.DisplayName(), alert.SuccessThreshold)
} else {
message = fmt.Sprintf("An alert for `%s` has been triggered due to having failed %d time(s) in a row", endpoint.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✓"
} else {
prefix = "✕"
}
results += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += results
title := "Gatus: " + endpoint.DisplayName()
if provider.Title != "" {
title = provider.Title
}
body, _ := json.Marshal(Body{
Message: message,
Title: title,
Priority: provider.Priority,
})
return body
}

// GetDefaultAlert returns the provider's default alert configuration
func (provider AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
105 changes: 105 additions & 0 deletions alerting/provider/gotify/gotify_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
package gotify

import (
"encoding/json"
"fmt"
"testing"

"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/core"
)

func TestAlertProvider_IsValid(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
expected: true,
},
{
name: "invalid-server-url",
provider: AlertProvider{ServerURL: "", Token: "faketoken"},
expected: false,
},
{
name: "invalid-app-token",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""},
expected: false,
},
{
name: "no-priority-should-use-default-value",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid())
}
})
}
}

func TestAlertProvider_buildRequestBody(t *testing.T) {
var (
description = "custom-description"
//title = "custom-title"
endpoint = "custom-endpoint"
)
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description),
},
{
Name: "resolved",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpoint, description),
},
{
Name: "custom-title",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpoint, description),
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(
&core.Endpoint{Name: endpoint},
&scenario.Alert,
&core.Result{
ConditionResults: []*core.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
1 change: 1 addition & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*core.E
alert.TypeGitHub,
alert.TypeGitLab,
alert.TypeGoogleChat,
alert.TypeGotify,
alert.TypeEmail,
alert.TypeMatrix,
alert.TypeMattermost,
Expand Down
Loading