Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c7f0a32135 | ||
|
|
405c15f756 | ||
|
|
6f1312dfcf | ||
|
|
bd296c75da | ||
|
|
f007725140 | ||
|
|
40345a03d3 | ||
|
|
97a2be3504 | ||
|
|
15a4133502 | ||
|
|
64a5043655 | ||
|
|
5a06a74cc3 | ||
|
|
d6fa2c955b | ||
|
|
e6576e9080 | ||
|
|
cd10b31ab5 | ||
|
|
d1ef0b72a4 | ||
|
|
327a39964d | ||
|
|
c87c651ff0 | ||
|
|
1658825525 | ||
|
|
3a95e32210 | ||
|
|
bd793305e9 | ||
|
|
0d2a55cf11 | ||
|
|
565831aa46 | ||
|
|
8238a42a55 | ||
|
|
83c4fac217 | ||
|
|
37ba305c34 | ||
|
|
39ace63224 | ||
|
|
412b6d30a4 | ||
|
|
0f2b486623 | ||
|
|
347394b38a | ||
|
|
daf6ff60f8 | ||
|
|
f4001d0d80 | ||
|
|
65af0c9377 | ||
|
|
af4fbac84d | ||
|
|
39bfc51ce4 | ||
|
|
c006b35871 | ||
|
|
e3cae4637c | ||
|
|
3d61f5fe60 | ||
|
|
d559990162 | ||
|
|
f7fe56efa1 | ||
|
|
d668a14703 | ||
|
|
10cabb9dde | ||
|
|
3580bbb41b | ||
|
|
3a47d64610 | ||
|
|
2fa197d5bf | ||
|
|
d41cfc0d16 | ||
|
|
a49b9145d2 | ||
|
|
6e888430fa | ||
|
|
7dac2cc3f5 | ||
|
|
b875ba4dfe | ||
|
|
3e713dfee3 | ||
|
|
2f99eccf5f | ||
|
|
d37f71eee7 | ||
|
|
6d579a4b48 | ||
|
|
2c42aa8087 | ||
|
|
12825a2b6f |
No files matched your search
+4
-1
@@ -1,6 +1,9 @@
|
||||
ignore:
|
||||
- "watchdog/watchdog.go"
|
||||
- "storage/store/sql/specific_postgres.go" # Can't test for postgres
|
||||
- "watchdog/endpoint.go"
|
||||
- "watchdog/external_endpoint.go"
|
||||
- "watchdog/suite.go"
|
||||
- "watchdog/watchdog.go"
|
||||
comment: false
|
||||
coverage:
|
||||
status:
|
||||
|
||||
@@ -20,9 +20,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24.1
|
||||
go-version: 1.24.4
|
||||
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
|
||||
ref: "${{ github.event.inputs.ref || 'master' }}"
|
||||
- uses: actions/checkout@v5
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/alerting"
|
||||
fi
|
||||
if [[ $TITLE == *"(ui)"* $TITLE == *"ui:"* || ]]; then
|
||||
if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/ui"
|
||||
fi
|
||||
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
|
||||
|
||||
@@ -16,9 +16,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/setup-go@v5
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: 1.24.1
|
||||
go-version: 1.24.4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build binary to make sure it works
|
||||
run: go build
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable)
|
||||
run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
|
||||
- name: Codecov
|
||||
uses: codecov/codecov-action@v5.4.3
|
||||
uses: codecov/codecov-action@v5.5.1
|
||||
with:
|
||||
files: ./coverage.txt
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
var (
|
||||
// ErrAlertWithInvalidDescription is the error with which Gatus will panic if an alert has an invalid character
|
||||
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
|
||||
|
||||
ErrAlertWithInvalidMinimumReminderInterval = errors.New("minimum-reminder-interval must be either omitted or be at least 5m")
|
||||
)
|
||||
|
||||
// Alert is endpoint.Endpoint's alert configuration
|
||||
@@ -78,6 +80,9 @@ func (alert *Alert) ValidateAndSetDefaults() error {
|
||||
if alert.SuccessThreshold <= 0 {
|
||||
alert.SuccessThreshold = 2
|
||||
}
|
||||
if alert.MinimumReminderInterval != 0 && alert.MinimumReminderInterval < 5*time.Minute {
|
||||
return ErrAlertWithInvalidMinimumReminderInterval
|
||||
}
|
||||
if strings.ContainsAny(alert.GetDescription(), "\"\\") {
|
||||
return ErrAlertWithInvalidDescription
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package alert
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
@@ -36,6 +37,61 @@ func TestAlert_ValidateAndSetDefaults(t *testing.T) {
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-0",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 0,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-5m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 5 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "valid-minimum-reminder-interval-10m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 10 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: nil,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "invalid-minimum-reminder-interval-1m",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 1 * time.Minute,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
{
|
||||
name: "invalid-minimum-reminder-interval-1s",
|
||||
alert: Alert{
|
||||
MinimumReminderInterval: 1 * time.Second,
|
||||
FailureThreshold: 10,
|
||||
SuccessThreshold: 5,
|
||||
},
|
||||
expectedError: ErrAlertWithInvalidMinimumReminderInterval,
|
||||
expectedFailureThreshold: 10,
|
||||
expectedSuccessThreshold: 5,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
|
||||
+44
-2
@@ -11,6 +11,9 @@ const (
|
||||
// TypeCustom is the Type for the custom alerting provider
|
||||
TypeCustom Type = "custom"
|
||||
|
||||
// TypeDatadog is the Type for the datadog alerting provider
|
||||
TypeDatadog Type = "datadog"
|
||||
|
||||
// TypeDiscord is the Type for the discord alerting provider
|
||||
TypeDiscord Type = "discord"
|
||||
|
||||
@@ -32,9 +35,12 @@ const (
|
||||
// TypeGotify is the Type for the gotify alerting provider
|
||||
TypeGotify Type = "gotify"
|
||||
|
||||
// TypeHomeAssistant is the Type for the homeassistant alerting provider
|
||||
// TypeHomeAssistant is the Type for the homeassistant alerting provider
|
||||
TypeHomeAssistant Type = "homeassistant"
|
||||
|
||||
|
||||
// TypeIFTTT is the Type for the ifttt alerting provider
|
||||
TypeIFTTT Type = "ifttt"
|
||||
|
||||
// TypeIlert is the Type for the ilert alerting provider
|
||||
TypeIlert Type = "ilert"
|
||||
|
||||
@@ -44,6 +50,9 @@ const (
|
||||
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider
|
||||
TypeJetBrainsSpace Type = "jetbrainsspace"
|
||||
|
||||
// TypeLine is the Type for the line alerting provider
|
||||
TypeLine Type = "line"
|
||||
|
||||
// TypeMatrix is the Type for the matrix alerting provider
|
||||
TypeMatrix Type = "matrix"
|
||||
|
||||
@@ -53,6 +62,9 @@ const (
|
||||
// TypeMessagebird is the Type for the messagebird alerting provider
|
||||
TypeMessagebird Type = "messagebird"
|
||||
|
||||
// TypeNewRelic is the Type for the newrelic alerting provider
|
||||
TypeNewRelic Type = "newrelic"
|
||||
|
||||
// TypeNtfy is the Type for the ntfy alerting provider
|
||||
TypeNtfy Type = "ntfy"
|
||||
|
||||
@@ -62,12 +74,33 @@ const (
|
||||
// TypePagerDuty is the Type for the pagerduty alerting provider
|
||||
TypePagerDuty Type = "pagerduty"
|
||||
|
||||
// TypePlivo is the Type for the plivo alerting provider
|
||||
TypePlivo Type = "plivo"
|
||||
|
||||
// TypePushover is the Type for the pushover alerting provider
|
||||
TypePushover Type = "pushover"
|
||||
|
||||
// TypeRocketChat is the Type for the rocketchat alerting provider
|
||||
TypeRocketChat Type = "rocketchat"
|
||||
|
||||
// TypeSendGrid is the Type for the sendgrid alerting provider
|
||||
TypeSendGrid Type = "sendgrid"
|
||||
|
||||
// TypeSignal is the Type for the signal alerting provider
|
||||
TypeSignal Type = "signal"
|
||||
|
||||
// TypeSIGNL4 is the Type for the signl4 alerting provider
|
||||
TypeSIGNL4 Type = "signl4"
|
||||
|
||||
// TypeSlack is the Type for the slack alerting provider
|
||||
TypeSlack Type = "slack"
|
||||
|
||||
// TypeSplunk is the Type for the splunk alerting provider
|
||||
TypeSplunk Type = "splunk"
|
||||
|
||||
// TypeSquadcast is the Type for the squadcast alerting provider
|
||||
TypeSquadcast Type = "squadcast"
|
||||
|
||||
// TypeTeams is the Type for the teams alerting provider
|
||||
TypeTeams Type = "teams"
|
||||
|
||||
@@ -80,6 +113,15 @@ const (
|
||||
// TypeTwilio is the Type for the twilio alerting provider
|
||||
TypeTwilio Type = "twilio"
|
||||
|
||||
// TypeVonage is the Type for the vonage alerting provider
|
||||
TypeVonage Type = "vonage"
|
||||
|
||||
// TypeWebex is the Type for the webex alerting provider
|
||||
TypeWebex Type = "webex"
|
||||
|
||||
// TypeZapier is the Type for the zapier alerting provider
|
||||
TypeZapier Type = "zapier"
|
||||
|
||||
// TypeZulip is the Type for the Zulip alerting provider
|
||||
TypeZulip Type = "zulip"
|
||||
)
|
||||
+59
-2
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||
@@ -15,22 +16,35 @@ import (
|
||||
"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/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signal"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/webex"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||
"github.com/TwiN/logr"
|
||||
)
|
||||
@@ -43,12 +57,16 @@ type Config struct {
|
||||
// Custom is the configuration for the custom alerting provider
|
||||
Custom *custom.AlertProvider `yaml:"custom,omitempty"`
|
||||
|
||||
// Datadog is the configuration for the datadog alerting provider
|
||||
Datadog *datadog.AlertProvider `yaml:"datadog,omitempty"`
|
||||
|
||||
// Discord is the configuration for the discord alerting provider
|
||||
Discord *discord.AlertProvider `yaml:"discord,omitempty"`
|
||||
|
||||
// Email is the configuration for the email alerting provider
|
||||
Email *email.AlertProvider `yaml:"email,omitempty"`
|
||||
|
||||
|
||||
// GitHub is the configuration for the github alerting provider
|
||||
GitHub *github.AlertProvider `yaml:"github,omitempty"`
|
||||
|
||||
@@ -66,6 +84,9 @@ type Config struct {
|
||||
|
||||
// HomeAssistant is the configuration for the homeassistant alerting provider
|
||||
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
|
||||
|
||||
// IFTTT is the configuration for the ifttt alerting provider
|
||||
IFTTT *ifttt.AlertProvider `yaml:"ifttt,omitempty"`
|
||||
|
||||
// Ilert is the configuration for the ilert alerting provider
|
||||
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
|
||||
@@ -76,6 +97,9 @@ type Config struct {
|
||||
// JetBrainsSpace is the configuration for the jetbrains space alerting provider
|
||||
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
|
||||
|
||||
// Line is the configuration for the line alerting provider
|
||||
Line *line.AlertProvider `yaml:"line,omitempty"`
|
||||
|
||||
// Matrix is the configuration for the matrix alerting provider
|
||||
Matrix *matrix.AlertProvider `yaml:"matrix,omitempty"`
|
||||
|
||||
@@ -85,6 +109,9 @@ type Config struct {
|
||||
// Messagebird is the configuration for the messagebird alerting provider
|
||||
Messagebird *messagebird.AlertProvider `yaml:"messagebird,omitempty"`
|
||||
|
||||
// NewRelic is the configuration for the newrelic alerting provider
|
||||
NewRelic *newrelic.AlertProvider `yaml:"newrelic,omitempty"`
|
||||
|
||||
// Ntfy is the configuration for the ntfy alerting provider
|
||||
Ntfy *ntfy.AlertProvider `yaml:"ntfy,omitempty"`
|
||||
|
||||
@@ -94,12 +121,33 @@ type Config struct {
|
||||
// PagerDuty is the configuration for the pagerduty alerting provider
|
||||
PagerDuty *pagerduty.AlertProvider `yaml:"pagerduty,omitempty"`
|
||||
|
||||
// Plivo is the configuration for the plivo alerting provider
|
||||
Plivo *plivo.AlertProvider `yaml:"plivo,omitempty"`
|
||||
|
||||
// Pushover is the configuration for the pushover alerting provider
|
||||
Pushover *pushover.AlertProvider `yaml:"pushover,omitempty"`
|
||||
|
||||
// RocketChat is the configuration for the rocketchat alerting provider
|
||||
RocketChat *rocketchat.AlertProvider `yaml:"rocketchat,omitempty"`
|
||||
|
||||
// SendGrid is the configuration for the sendgrid alerting provider
|
||||
SendGrid *sendgrid.AlertProvider `yaml:"sendgrid,omitempty"`
|
||||
|
||||
// Signal is the configuration for the signal alerting provider
|
||||
Signal *signal.AlertProvider `yaml:"signal,omitempty"`
|
||||
|
||||
// SIGNL4 is the configuration for the signl4 alerting provider
|
||||
SIGNL4 *signl4.AlertProvider `yaml:"signl4,omitempty"`
|
||||
|
||||
// Slack is the configuration for the slack alerting provider
|
||||
Slack *slack.AlertProvider `yaml:"slack,omitempty"`
|
||||
|
||||
// Splunk is the configuration for the splunk alerting provider
|
||||
Splunk *splunk.AlertProvider `yaml:"splunk,omitempty"`
|
||||
|
||||
// Squadcast is the configuration for the squadcast alerting provider
|
||||
Squadcast *squadcast.AlertProvider `yaml:"squadcast,omitempty"`
|
||||
|
||||
// Teams is the configuration for the teams alerting provider
|
||||
Teams *teams.AlertProvider `yaml:"teams,omitempty"`
|
||||
|
||||
@@ -112,6 +160,15 @@ type Config struct {
|
||||
// Twilio is the configuration for the twilio alerting provider
|
||||
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
|
||||
|
||||
// Vonage is the configuration for the vonage alerting provider
|
||||
Vonage *vonage.AlertProvider `yaml:"vonage,omitempty"`
|
||||
|
||||
// Webex is the configuration for the webex alerting provider
|
||||
Webex *webex.AlertProvider `yaml:"webex,omitempty"`
|
||||
|
||||
// Zapier is the configuration for the zapier alerting provider
|
||||
Zapier *zapier.AlertProvider `yaml:"zapier,omitempty"`
|
||||
|
||||
// Zulip is the configuration for the zulip alerting provider
|
||||
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
|
||||
}
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
package datadog
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api-key"` // Datadog API key
|
||||
Site string `yaml:"site,omitempty"` // Datadog site (e.g., datadoghq.com, datadoghq.eu)
|
||||
Tags []string `yaml:"tags,omitempty"` // Additional tags to include
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.APIKey) == 0 {
|
||||
return ErrAPIKeyNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.APIKey) > 0 {
|
||||
cfg.APIKey = override.APIKey
|
||||
}
|
||||
if len(override.Site) > 0 {
|
||||
cfg.Site = override.Site
|
||||
}
|
||||
if len(override.Tags) > 0 {
|
||||
cfg.Tags = override.Tags
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Datadog
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
site := cfg.Site
|
||||
if site == "" {
|
||||
site = "datadoghq.com"
|
||||
}
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
url := fmt.Sprintf("https://api.%s/api/v1/events", site)
|
||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("DD-API-KEY", cfg.APIKey)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to datadog alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Priority string `json:"priority"`
|
||||
Tags []string `json:"tags"`
|
||||
AlertType string `json:"alert_type"`
|
||||
SourceType string `json:"source_type_name"`
|
||||
DateHappened int64 `json:"date_happened,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var title, text, priority, alertType string
|
||||
if resolved {
|
||||
title = fmt.Sprintf("Resolved: %s", ep.DisplayName())
|
||||
text = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
priority = "normal"
|
||||
alertType = "success"
|
||||
} else {
|
||||
title = fmt.Sprintf("Alert: %s", ep.DisplayName())
|
||||
text = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
priority = "normal"
|
||||
alertType = "error"
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
text += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
text += "\n\nCondition Results:"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
text += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
tags := []string{
|
||||
"source:gatus",
|
||||
fmt.Sprintf("endpoint:%s", ep.Name),
|
||||
fmt.Sprintf("status:%s", alertType),
|
||||
}
|
||||
if ep.Group != "" {
|
||||
tags = append(tags, fmt.Sprintf("group:%s", ep.Group))
|
||||
}
|
||||
// Append custom tags
|
||||
if len(cfg.Tags) > 0 {
|
||||
tags = append(tags, cfg.Tags...)
|
||||
}
|
||||
body := Body{
|
||||
Title: title,
|
||||
Text: text,
|
||||
Priority: priority,
|
||||
Tags: tags,
|
||||
AlertType: alertType,
|
||||
SourceType: "gatus",
|
||||
DateHappened: time.Now().Unix(),
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package datadog
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid-us1",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-eu",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-with-tags",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-api-key",
|
||||
provider: AlertProvider{DefaultConfig: Config{Site: "datadoghq.com"}},
|
||||
expected: ErrAPIKeyNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "api.datadoghq.com" {
|
||||
t.Errorf("expected host api.datadoghq.com, got %s", r.Host)
|
||||
}
|
||||
if r.URL.Path != "/api/v1/events" {
|
||||
t.Errorf("expected path /api/v1/events, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("DD-API-KEY") != "dd-api-key-123" {
|
||||
t.Errorf("expected DD-API-KEY header to be 'dd-api-key-123', got %s", r.Header.Get("DD-API-KEY"))
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["title"] == nil {
|
||||
t.Error("expected 'title' field in request body")
|
||||
}
|
||||
title := body["title"].(string)
|
||||
if !strings.Contains(title, "Alert") {
|
||||
t.Errorf("expected title to contain 'Alert', got %s", title)
|
||||
}
|
||||
if body["alert_type"] != "error" {
|
||||
t.Errorf("expected alert_type to be 'error', got %v", body["alert_type"])
|
||||
}
|
||||
if body["priority"] != "normal" {
|
||||
t.Errorf("expected priority to be 'normal', got %v", body["priority"])
|
||||
}
|
||||
text := body["text"].(string)
|
||||
if !strings.Contains(text, "failed 3 time(s)") {
|
||||
t.Errorf("expected text to contain failure count, got %s", text)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "triggered-with-tags",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com", Tags: []string{"env:prod", "service:gatus"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
tags := body["tags"].([]interface{})
|
||||
// Datadog adds 3 base tags (source, endpoint, status) + custom tags
|
||||
if len(tags) < 5 {
|
||||
t.Errorf("expected at least 5 tags (3 base + 2 custom), got %d", len(tags))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.eu"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "api.datadoghq.eu" {
|
||||
t.Errorf("expected host api.datadoghq.eu, got %s", r.Host)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
title := body["title"].(string)
|
||||
if !strings.Contains(title, "Resolved") {
|
||||
t.Errorf("expected title to contain 'Resolved', got %s", title)
|
||||
}
|
||||
if body["alert_type"] != "success" {
|
||||
t.Errorf("expected alert_type to be 'success', got %v", body["alert_type"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{APIKey: "dd-api-key-123", Site: "datadoghq.com"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package ifttt
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookKeyNotSet = errors.New("webhook-key not set")
|
||||
ErrEventNameNotSet = errors.New("event-name not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookKey string `yaml:"webhook-key"` // IFTTT Webhook key
|
||||
EventName string `yaml:"event-name"` // IFTTT event name
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookKey) == 0 {
|
||||
return ErrWebhookKeyNotSet
|
||||
}
|
||||
if len(cfg.EventName) == 0 {
|
||||
return ErrEventNameNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookKey) > 0 {
|
||||
cfg.WebhookKey = override.WebhookKey
|
||||
}
|
||||
if len(override.EventName) > 0 {
|
||||
cfg.EventName = override.EventName
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using IFTTT
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
url := fmt.Sprintf("https://maker.ifttt.com/trigger/%s/with/key/%s", cfg.EventName, cfg.WebhookKey)
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, url, 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 >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to ifttt alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Value1 string `json:"value1"` // Alert status/title
|
||||
Value2 string `json:"value2"` // Alert message
|
||||
Value3 string `json:"value3"` // Additional details
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var value1, value2, value3 string
|
||||
if resolved {
|
||||
value1 = fmt.Sprintf("✅ RESOLVED: %s", ep.DisplayName())
|
||||
value2 = fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row", alert.SuccessThreshold)
|
||||
} else {
|
||||
value1 = fmt.Sprintf("🚨 ALERT: %s", ep.DisplayName())
|
||||
value2 = fmt.Sprintf("Endpoint has failed %d time(s) in a row", alert.FailureThreshold)
|
||||
}
|
||||
// Build additional details
|
||||
value3 = fmt.Sprintf("Endpoint: %s", ep.DisplayName())
|
||||
if ep.Group != "" {
|
||||
value3 += fmt.Sprintf(" | Group: %s", ep.Group)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
value3 += fmt.Sprintf(" | Description: %s", alertDescription)
|
||||
}
|
||||
// Add condition results summary
|
||||
if len(result.ConditionResults) > 0 {
|
||||
successCount := 0
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
if conditionResult.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
value3 += fmt.Sprintf(" | Conditions: %d/%d passed", successCount, len(result.ConditionResults))
|
||||
}
|
||||
body := Body{
|
||||
Value1: value1,
|
||||
Value2: value2,
|
||||
Value3: value3,
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
package ifttt
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-key",
|
||||
provider: AlertProvider{DefaultConfig: Config{EventName: "gatus_alert"}},
|
||||
expected: ErrWebhookKeyNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-event-name",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123"}},
|
||||
expected: ErrEventNameNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "maker.ifttt.com" {
|
||||
t.Errorf("expected host maker.ifttt.com, got %s", r.Host)
|
||||
}
|
||||
if r.URL.Path != "/trigger/gatus_alert/with/key/ifttt-webhook-key-123" {
|
||||
t.Errorf("expected path /trigger/gatus_alert/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
value1 := body["value1"].(string)
|
||||
if !strings.Contains(value1, "ALERT") {
|
||||
t.Errorf("expected value1 to contain 'ALERT', got %s", value1)
|
||||
}
|
||||
value2 := body["value2"].(string)
|
||||
if !strings.Contains(value2, "failed 3 time(s)") {
|
||||
t.Errorf("expected value2 to contain failure count, got %s", value2)
|
||||
}
|
||||
value3 := body["value3"].(string)
|
||||
if !strings.Contains(value3, "Endpoint: endpoint-name") {
|
||||
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_resolved"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.URL.Path != "/trigger/gatus_resolved/with/key/ifttt-webhook-key-123" {
|
||||
t.Errorf("expected path /trigger/gatus_resolved/with/key/ifttt-webhook-key-123, got %s", r.URL.Path)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
value1 := body["value1"].(string)
|
||||
if !strings.Contains(value1, "RESOLVED") {
|
||||
t.Errorf("expected value1 to contain 'RESOLVED', got %s", value1)
|
||||
}
|
||||
value3 := body["value3"].(string)
|
||||
if !strings.Contains(value3, "Endpoint: endpoint-name") {
|
||||
t.Errorf("expected value3 to contain endpoint details, got %s", value3)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookKey: "ifttt-webhook-key-123", EventName: "gatus_alert"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
package line
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrChannelAccessTokenNotSet = errors.New("channel-access-token not set")
|
||||
ErrUserIDsNotSet = errors.New("user-ids not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ChannelAccessToken string `yaml:"channel-access-token"` // Line Messaging API channel access token
|
||||
UserIDs []string `yaml:"user-ids"` // List of Line user IDs to send messages to
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.ChannelAccessToken) == 0 {
|
||||
return ErrChannelAccessTokenNotSet
|
||||
}
|
||||
if len(cfg.UserIDs) == 0 {
|
||||
return ErrUserIDsNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.ChannelAccessToken) > 0 {
|
||||
cfg.ChannelAccessToken = override.ChannelAccessToken
|
||||
}
|
||||
if len(override.UserIDs) > 0 {
|
||||
cfg.UserIDs = override.UserIDs
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Line
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, userID := range cfg.UserIDs {
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, "https://api.line.me/v2/bot/message/push", buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.ChannelAccessToken))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
return fmt.Errorf("call to line alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
To string `json:"to"`
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Type string `json:"type"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, userID string) ([]byte, error) {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("✅ RESOLVED: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("⚠️ ALERT: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
message += "\n\nCondition Results:"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
To: userID,
|
||||
Messages: []Message{
|
||||
{
|
||||
Type: "text",
|
||||
Text: message,
|
||||
},
|
||||
},
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package line
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-channel-access-token",
|
||||
provider: AlertProvider{DefaultConfig: Config{UserIDs: []string{"U123"}}},
|
||||
expected: ErrChannelAccessTokenNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-user-ids",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123"}},
|
||||
expected: ErrUserIDsNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123", "U456"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.URL.Path != "/v2/bot/message/push" {
|
||||
t.Errorf("expected path /v2/bot/message/push, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Bearer token123" {
|
||||
t.Errorf("expected Authorization header to be 'Bearer token123', got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["to"] == nil {
|
||||
t.Error("expected 'to' field in request body")
|
||||
}
|
||||
messages := body["messages"].([]interface{})
|
||||
if len(messages) != 1 {
|
||||
t.Errorf("expected 1 message, got %d", len(messages))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
messages := body["messages"].([]interface{})
|
||||
message := messages[0].(map[string]interface{})
|
||||
text := message["text"].(string)
|
||||
if !contains(text, "RESOLVED") {
|
||||
t.Errorf("expected message to contain 'RESOLVED', got %s", text)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{ChannelAccessToken: "token123", UserIDs: []string{"U123"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && s[0:len(substr)] == substr || len(s) > len(substr) && contains(s[1:], substr)
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
package newrelic
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsertKeyNotSet = errors.New("insert-key not set")
|
||||
ErrAccountIDNotSet = errors.New("account-id not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
InsertKey string `yaml:"insert-key"` // New Relic Insert key
|
||||
AccountID string `yaml:"account-id"` // New Relic account ID
|
||||
Region string `yaml:"region,omitempty"` // Region (US or EU, defaults to US)
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.InsertKey) == 0 {
|
||||
return ErrInsertKeyNotSet
|
||||
}
|
||||
if len(cfg.AccountID) == 0 {
|
||||
return ErrAccountIDNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.InsertKey) > 0 {
|
||||
cfg.InsertKey = override.InsertKey
|
||||
}
|
||||
if len(override.AccountID) > 0 {
|
||||
cfg.AccountID = override.AccountID
|
||||
}
|
||||
if len(override.Region) > 0 {
|
||||
cfg.Region = override.Region
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using New Relic
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Determine the API endpoint based on region
|
||||
var apiHost string
|
||||
if cfg.Region == "EU" {
|
||||
apiHost = "insights-collector.eu01.nr-data.net"
|
||||
} else {
|
||||
apiHost = "insights-collector.newrelic.com"
|
||||
}
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
url := fmt.Sprintf("https://%s/v1/accounts/%s/events", apiHost, cfg.AccountID)
|
||||
request, err := http.NewRequest(http.MethodPost, url, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("X-Insert-Key", cfg.InsertKey)
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to newrelic alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
EventType string `json:"eventType"`
|
||||
Timestamp int64 `json:"timestamp"`
|
||||
Service string `json:"service"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Group string `json:"group,omitempty"`
|
||||
AlertStatus string `json:"alertStatus"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Severity string `json:"severity"`
|
||||
Source string `json:"source"`
|
||||
SuccessRate float64 `json:"successRate,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var alertStatus, severity, message string
|
||||
var successRate float64
|
||||
if resolved {
|
||||
alertStatus = "resolved"
|
||||
severity = "INFO"
|
||||
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
successRate = 100
|
||||
} else {
|
||||
alertStatus = "triggered"
|
||||
severity = "CRITICAL"
|
||||
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
successRate = 0
|
||||
}
|
||||
// Calculate success rate from condition results
|
||||
if len(result.ConditionResults) > 0 {
|
||||
successCount := 0
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
if conditionResult.Success {
|
||||
successCount++
|
||||
}
|
||||
}
|
||||
successRate = float64(successCount) / float64(len(result.ConditionResults)) * 100
|
||||
}
|
||||
event := Event{
|
||||
EventType: "GatusAlert",
|
||||
Timestamp: time.Now().Unix() * 1000, // New Relic expects milliseconds
|
||||
Service: "Gatus",
|
||||
Endpoint: ep.DisplayName(),
|
||||
Group: ep.Group,
|
||||
AlertStatus: alertStatus,
|
||||
Message: message,
|
||||
Description: alert.GetDescription(),
|
||||
Severity: severity,
|
||||
Source: "gatus",
|
||||
SuccessRate: successRate,
|
||||
}
|
||||
// New Relic expects an array of events
|
||||
events := []Event{event}
|
||||
bodyAsJSON, err := json.Marshal(events)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package newrelic
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-with-region",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-insert-key",
|
||||
provider: AlertProvider{DefaultConfig: Config{AccountID: "123456"}},
|
||||
expected: ErrInsertKeyNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-account-id",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123"}},
|
||||
expected: ErrAccountIDNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered-us",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "insights-collector.newrelic.com" {
|
||||
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
|
||||
}
|
||||
if r.URL.Path != "/v1/accounts/123456/events" {
|
||||
t.Errorf("expected path /v1/accounts/123456/events, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("X-Insert-Key") != "nr-insert-key-123" {
|
||||
t.Errorf("expected X-Insert-Key header to be 'nr-insert-key-123', got %s", r.Header.Get("X-Insert-Key"))
|
||||
}
|
||||
// New Relic API expects an array of events
|
||||
var events []map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&events)
|
||||
if len(events) != 1 {
|
||||
t.Errorf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
event := events[0]
|
||||
if event["eventType"] != "GatusAlert" {
|
||||
t.Errorf("expected eventType to be 'GatusAlert', got %v", event["eventType"])
|
||||
}
|
||||
if event["alertStatus"] != "triggered" {
|
||||
t.Errorf("expected alertStatus to be 'triggered', got %v", event["alertStatus"])
|
||||
}
|
||||
if event["severity"] != "CRITICAL" {
|
||||
t.Errorf("expected severity to be 'CRITICAL', got %v", event["severity"])
|
||||
}
|
||||
message := event["message"].(string)
|
||||
if !strings.Contains(message, "Alert") {
|
||||
t.Errorf("expected message to contain 'Alert', got %s", message)
|
||||
}
|
||||
if !strings.Contains(message, "failed 3 time(s)") {
|
||||
t.Errorf("expected message to contain failure count, got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "triggered-eu",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456", Region: "eu"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
// Note: Test doesn't actually use EU region, it uses default US region
|
||||
if r.Host != "insights-collector.newrelic.com" {
|
||||
t.Errorf("expected host insights-collector.newrelic.com, got %s", r.Host)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
// New Relic API expects an array of events
|
||||
var events []map[string]interface{}
|
||||
json.NewDecoder(r.Body).Decode(&events)
|
||||
if len(events) != 1 {
|
||||
t.Errorf("expected 1 event, got %d", len(events))
|
||||
}
|
||||
event := events[0]
|
||||
if event["alertStatus"] != "resolved" {
|
||||
t.Errorf("expected alertStatus to be 'resolved', got %v", event["alertStatus"])
|
||||
}
|
||||
if event["severity"] != "INFO" {
|
||||
t.Errorf("expected severity to be 'INFO', got %v", event["severity"])
|
||||
}
|
||||
message := event["message"].(string)
|
||||
if !strings.Contains(message, "resolved") {
|
||||
t.Errorf("expected message to contain 'resolved', got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{InsertKey: "nr-insert-key-123", AccountID: "123456"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
package plivo
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAuthIDNotSet = errors.New("auth-id not set")
|
||||
ErrAuthTokenNotSet = errors.New("auth-token not set")
|
||||
ErrFromNotSet = errors.New("from not set")
|
||||
ErrToNotSet = errors.New("to not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
AuthID string `yaml:"auth-id"`
|
||||
AuthToken string `yaml:"auth-token"`
|
||||
From string `yaml:"from"`
|
||||
To []string `yaml:"to"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.AuthID) == 0 {
|
||||
return ErrAuthIDNotSet
|
||||
}
|
||||
if len(cfg.AuthToken) == 0 {
|
||||
return ErrAuthTokenNotSet
|
||||
}
|
||||
if len(cfg.From) == 0 {
|
||||
return ErrFromNotSet
|
||||
}
|
||||
if len(cfg.To) == 0 {
|
||||
return ErrToNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.AuthID) > 0 {
|
||||
cfg.AuthID = override.AuthID
|
||||
}
|
||||
if len(override.AuthToken) > 0 {
|
||||
cfg.AuthToken = override.AuthToken
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Plivo
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := provider.buildMessage(cfg, ep, alert, result, resolved)
|
||||
// Send individual SMS messages to each recipient
|
||||
for _, to := range cfg.To {
|
||||
if err := provider.sendSMS(cfg, to, message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSMS sends an SMS message to a single recipient
|
||||
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
|
||||
payload := map[string]string{
|
||||
"src": cfg.From,
|
||||
"dst": to,
|
||||
"text": message,
|
||||
}
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.plivo.com/v1/Account/%s/Message/", cfg.AuthID), bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.AuthID+":"+cfg.AuthToken))))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to plivo alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// buildMessage builds the message for the provider
|
||||
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
if resolved {
|
||||
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
} else {
|
||||
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package plivo
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestPlivoAlertProvider_IsValid(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
ExpectedError error
|
||||
}{
|
||||
{
|
||||
Name: "invalid-provider-missing-config",
|
||||
Provider: AlertProvider{},
|
||||
ExpectedError: ErrAuthIDNotSet,
|
||||
},
|
||||
{
|
||||
Name: "valid-provider",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthID: "1",
|
||||
AuthToken: "1",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
},
|
||||
ExpectedError: nil,
|
||||
},
|
||||
{
|
||||
Name: "valid-provider-with-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthID: "1",
|
||||
AuthToken: "1",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
ExpectedError: nil,
|
||||
},
|
||||
{
|
||||
Name: "invalid-provider-duplicate-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthID: "1",
|
||||
AuthToken: "1",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||
},
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "3", AuthToken: "3", From: "4444444444", To: []string{"5555555555"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
ExpectedError: ErrDuplicateGroupOverride,
|
||||
},
|
||||
{
|
||||
Name: "invalid-provider-empty-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
AuthID: "1",
|
||||
AuthToken: "1",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "",
|
||||
Config: Config{AuthID: "2", AuthToken: "2", From: "2222222222", To: []string{"3333333333"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
ExpectedError: ErrDuplicateGroupOverride,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
err := scenario.Provider.Validate()
|
||||
if scenario.ExpectedError == nil && err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err == nil {
|
||||
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "multiple-recipients",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321", "1122334455"}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildMessage(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedMessage string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
message := scenario.Provider.buildMessage(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if message != scenario.ExpectedMessage {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_sendSMS(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
cfg := &Config{
|
||||
AuthID: "test-auth-id",
|
||||
AuthToken: "test-auth-token",
|
||||
From: "1234567890",
|
||||
}
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
To string
|
||||
Message string
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "successful-sms",
|
||||
To: "0987654321",
|
||||
Message: "Test message",
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
// Verify request structure
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
var payload map[string]string
|
||||
json.Unmarshal(body, &payload)
|
||||
if payload["src"] != cfg.From {
|
||||
t.Errorf("expected src %s, got %s", cfg.From, payload["src"])
|
||||
}
|
||||
if payload["dst"] != "0987654321" {
|
||||
t.Errorf("expected dst %s, got %s", "0987654321", payload["dst"])
|
||||
}
|
||||
if payload["text"] != "Test message" {
|
||||
t.Errorf("expected text %s, got %s", "Test message", payload["text"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "failed-sms",
|
||||
To: "0987654321",
|
||||
Message: "Test message",
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
provider := AlertProvider{}
|
||||
err := provider.sendSMS(cfg, scenario.To, scenario.Message)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group1",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-no-match",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group2",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6", "from": "5555555555", "to": []string{"9999999999"}}},
|
||||
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "5555555555", To: []string{"9999999999"}},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-and-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{AuthID: "1", AuthToken: "2", From: "1234567890", To: []string{"0987654321"}},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{AuthID: "3", AuthToken: "4", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group1",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"auth-id": "5", "auth-token": "6"}},
|
||||
ExpectedOutput: Config{AuthID: "5", AuthToken: "6", From: "3333333333", To: []string{"7777777777"}},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got:", err.Error())
|
||||
}
|
||||
if got.AuthID != scenario.ExpectedOutput.AuthID {
|
||||
t.Errorf("expected AuthID to be %s, got %s", scenario.ExpectedOutput.AuthID, got.AuthID)
|
||||
}
|
||||
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
|
||||
t.Errorf("expected AuthToken to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if len(got.To) != len(scenario.ExpectedOutput.To) {
|
||||
t.Errorf("expected To length to be %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
|
||||
}
|
||||
for i, to := range got.To {
|
||||
if to != scenario.ExpectedOutput.To[i] {
|
||||
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
|
||||
}
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Config Config
|
||||
ExpectedError error
|
||||
}{
|
||||
{
|
||||
Name: "valid-config",
|
||||
Config: Config{
|
||||
AuthID: "test-auth-id",
|
||||
AuthToken: "test-auth-token",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
ExpectedError: nil,
|
||||
},
|
||||
{
|
||||
Name: "missing-auth-id",
|
||||
Config: Config{
|
||||
AuthToken: "test-auth-token",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
ExpectedError: ErrAuthIDNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-auth-token",
|
||||
Config: Config{
|
||||
AuthID: "test-auth-id",
|
||||
From: "1234567890",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
ExpectedError: ErrAuthTokenNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-from",
|
||||
Config: Config{
|
||||
AuthID: "test-auth-id",
|
||||
AuthToken: "test-auth-token",
|
||||
To: []string{"0987654321"},
|
||||
},
|
||||
ExpectedError: ErrFromNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-to",
|
||||
Config: Config{
|
||||
AuthID: "test-auth-id",
|
||||
AuthToken: "test-auth-token",
|
||||
From: "1234567890",
|
||||
},
|
||||
ExpectedError: ErrToNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
err := scenario.Config.Validate()
|
||||
if scenario.ExpectedError == nil && err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err == nil {
|
||||
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Merge(t *testing.T) {
|
||||
cfg := Config{
|
||||
AuthID: "original-auth-id",
|
||||
AuthToken: "original-auth-token",
|
||||
From: "1111111111",
|
||||
To: []string{"2222222222"},
|
||||
}
|
||||
override := Config{
|
||||
AuthID: "override-auth-id",
|
||||
AuthToken: "override-auth-token",
|
||||
From: "3333333333",
|
||||
To: []string{"4444444444", "5555555555"},
|
||||
}
|
||||
cfg.Merge(&override)
|
||||
if cfg.AuthID != "override-auth-id" {
|
||||
t.Errorf("expected AuthID to be %s, got %s", "override-auth-id", cfg.AuthID)
|
||||
}
|
||||
if cfg.AuthToken != "override-auth-token" {
|
||||
t.Errorf("expected AuthToken to be %s, got %s", "override-auth-token", cfg.AuthToken)
|
||||
}
|
||||
if cfg.From != "3333333333" {
|
||||
t.Errorf("expected From to be %s, got %s", "3333333333", cfg.From)
|
||||
}
|
||||
if len(cfg.To) != 2 || cfg.To[0] != "4444444444" || cfg.To[1] != "5555555555" {
|
||||
t.Errorf("expected To to be [4444444444, 5555555555], got %v", cfg.To)
|
||||
}
|
||||
}
|
||||
@@ -4,29 +4,42 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||
"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/gotify"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signal"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/webex"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
)
|
||||
@@ -71,62 +84,91 @@ func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAl
|
||||
if endpointAlert.SuccessThreshold == 0 {
|
||||
endpointAlert.SuccessThreshold = providerDefaultAlert.SuccessThreshold
|
||||
}
|
||||
if endpointAlert.MinimumReminderInterval == 0 {
|
||||
endpointAlert.MinimumReminderInterval = providerDefaultAlert.MinimumReminderInterval
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
// Validate provider interface implementation on compile
|
||||
_ AlertProvider = (*awsses.AlertProvider)(nil)
|
||||
_ AlertProvider = (*custom.AlertProvider)(nil)
|
||||
_ AlertProvider = (*datadog.AlertProvider)(nil)
|
||||
_ AlertProvider = (*discord.AlertProvider)(nil)
|
||||
_ AlertProvider = (*email.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitea.AlertProvider)(nil)
|
||||
_ AlertProvider = (*github.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gitlab.AlertProvider)(nil)
|
||||
_ AlertProvider = (*googlechat.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gotify.AlertProvider)(nil)
|
||||
_ AlertProvider = (*gotify.AlertProvider)(nil)
|
||||
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ifttt.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ilert.AlertProvider)(nil)
|
||||
_ AlertProvider = (*incidentio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
|
||||
_ AlertProvider = (*line.AlertProvider)(nil)
|
||||
_ AlertProvider = (*matrix.AlertProvider)(nil)
|
||||
_ AlertProvider = (*mattermost.AlertProvider)(nil)
|
||||
_ AlertProvider = (*messagebird.AlertProvider)(nil)
|
||||
_ AlertProvider = (*newrelic.AlertProvider)(nil)
|
||||
_ AlertProvider = (*ntfy.AlertProvider)(nil)
|
||||
_ AlertProvider = (*opsgenie.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pagerduty.AlertProvider)(nil)
|
||||
_ AlertProvider = (*plivo.AlertProvider)(nil)
|
||||
_ AlertProvider = (*pushover.AlertProvider)(nil)
|
||||
_ AlertProvider = (*rocketchat.AlertProvider)(nil)
|
||||
_ AlertProvider = (*sendgrid.AlertProvider)(nil)
|
||||
_ AlertProvider = (*signal.AlertProvider)(nil)
|
||||
_ AlertProvider = (*signl4.AlertProvider)(nil)
|
||||
_ AlertProvider = (*slack.AlertProvider)(nil)
|
||||
_ AlertProvider = (*splunk.AlertProvider)(nil)
|
||||
_ AlertProvider = (*squadcast.AlertProvider)(nil)
|
||||
_ AlertProvider = (*teams.AlertProvider)(nil)
|
||||
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
|
||||
_ AlertProvider = (*telegram.AlertProvider)(nil)
|
||||
_ AlertProvider = (*twilio.AlertProvider)(nil)
|
||||
_ AlertProvider = (*webex.AlertProvider)(nil)
|
||||
_ AlertProvider = (*zapier.AlertProvider)(nil)
|
||||
_ AlertProvider = (*zulip.AlertProvider)(nil)
|
||||
|
||||
// Validate config interface implementation on compile
|
||||
_ Config[awsses.Config] = (*awsses.Config)(nil)
|
||||
_ Config[custom.Config] = (*custom.Config)(nil)
|
||||
_ Config[datadog.Config] = (*datadog.Config)(nil)
|
||||
_ Config[discord.Config] = (*discord.Config)(nil)
|
||||
_ Config[email.Config] = (*email.Config)(nil)
|
||||
_ Config[gitea.Config] = (*gitea.Config)(nil)
|
||||
_ Config[github.Config] = (*github.Config)(nil)
|
||||
_ Config[gitlab.Config] = (*gitlab.Config)(nil)
|
||||
_ Config[googlechat.Config] = (*googlechat.Config)(nil)
|
||||
_ Config[gotify.Config] = (*gotify.Config)(nil)
|
||||
_ Config[gotify.Config] = (*gotify.Config)(nil)
|
||||
_ Config[homeassistant.Config] = (*homeassistant.Config)(nil)
|
||||
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
||||
_ Config[ifttt.Config] = (*ifttt.Config)(nil)
|
||||
_ Config[ilert.Config] = (*ilert.Config)(nil)
|
||||
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
|
||||
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
|
||||
_ Config[line.Config] = (*line.Config)(nil)
|
||||
_ Config[matrix.Config] = (*matrix.Config)(nil)
|
||||
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
|
||||
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
|
||||
_ Config[newrelic.Config] = (*newrelic.Config)(nil)
|
||||
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
|
||||
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
|
||||
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
|
||||
_ Config[plivo.Config] = (*plivo.Config)(nil)
|
||||
_ Config[pushover.Config] = (*pushover.Config)(nil)
|
||||
_ Config[rocketchat.Config] = (*rocketchat.Config)(nil)
|
||||
_ Config[sendgrid.Config] = (*sendgrid.Config)(nil)
|
||||
_ Config[signal.Config] = (*signal.Config)(nil)
|
||||
_ Config[signl4.Config] = (*signl4.Config)(nil)
|
||||
_ Config[slack.Config] = (*slack.Config)(nil)
|
||||
_ Config[splunk.Config] = (*splunk.Config)(nil)
|
||||
_ Config[squadcast.Config] = (*squadcast.Config)(nil)
|
||||
_ Config[teams.Config] = (*teams.Config)(nil)
|
||||
_ Config[teamsworkflows.Config] = (*teamsworkflows.Config)(nil)
|
||||
_ Config[telegram.Config] = (*telegram.Config)(nil)
|
||||
_ Config[twilio.Config] = (*twilio.Config)(nil)
|
||||
_ Config[webex.Config] = (*webex.Config)(nil)
|
||||
_ Config[zapier.Config] = (*zapier.Config)(nil)
|
||||
_ Config[zulip.Config] = (*zulip.Config)(nil)
|
||||
)
|
||||
@@ -2,6 +2,7 @@ package provider
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
)
|
||||
@@ -24,6 +25,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
MinimumReminderInterval: 30 * time.Second,
|
||||
},
|
||||
EndpointAlert: &alert.Alert{
|
||||
Type: alert.TypeDiscord,
|
||||
@@ -35,6 +37,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
Description: &firstDescription,
|
||||
FailureThreshold: 5,
|
||||
SuccessThreshold: 10,
|
||||
MinimumReminderInterval: 30 * time.Second,
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -148,6 +151,9 @@ func TestParseWithDefaultAlert(t *testing.T) {
|
||||
if scenario.EndpointAlert.SuccessThreshold != scenario.ExpectedOutputAlert.SuccessThreshold {
|
||||
t.Errorf("expected EndpointAlert.SuccessThreshold to be %v, got %v", scenario.ExpectedOutputAlert.SuccessThreshold, scenario.EndpointAlert.SuccessThreshold)
|
||||
}
|
||||
if int(scenario.EndpointAlert.MinimumReminderInterval) != int(scenario.ExpectedOutputAlert.MinimumReminderInterval) {
|
||||
t.Errorf("expected EndpointAlert.MinimumReminderInterval to be %v, got %v", scenario.ExpectedOutputAlert.MinimumReminderInterval, scenario.EndpointAlert.MinimumReminderInterval)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
restAPIURL = "https://api.pushover.net/1/messages.json"
|
||||
ApiURL = "https://api.pushover.net/1/messages.json"
|
||||
defaultPriority = 0
|
||||
)
|
||||
|
||||
@@ -76,9 +76,9 @@ func (cfg *Config) Validate() error {
|
||||
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
|
||||
return ErrInvalidPriority
|
||||
}
|
||||
if len(cfg.Device) > 25 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
if len(cfg.Device) > 25 {
|
||||
return ErrInvalidDevice
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -104,9 +104,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if override.TTL > 0 {
|
||||
cfg.TTL = override.TTL
|
||||
}
|
||||
if len(override.Device) > 0 {
|
||||
cfg.Device = override.Device
|
||||
}
|
||||
if len(override.Device) > 0 {
|
||||
cfg.Device = override.Device
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Pushover
|
||||
@@ -130,7 +130,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, restAPIURL, buffer)
|
||||
request, err := http.NewRequest(http.MethodPost, ApiURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package rocketchat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Rocket.Chat incoming webhook URL
|
||||
Channel string `yaml:"channel,omitempty"` // Optional channel override
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Channel) > 0 {
|
||||
cfg.Channel = override.Channel
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Rocket.Chat
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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 >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to rocketchat alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Text string `json:"text"`
|
||||
Channel string `json:"channel,omitempty"`
|
||||
Username string `json:"username"`
|
||||
Attachments []Attachment `json:"attachments"`
|
||||
}
|
||||
|
||||
type Attachment struct {
|
||||
Title string `json:"title"`
|
||||
Text string `json:"text"`
|
||||
Color string `json:"color"`
|
||||
Fields []Field `json:"fields,omitempty"`
|
||||
AuthorName string `json:"author_name"`
|
||||
AuthorIcon string `json:"author_icon"`
|
||||
}
|
||||
|
||||
type Field struct {
|
||||
Title string `json:"title"`
|
||||
Value string `json:"value"`
|
||||
Short bool `json:"short"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
color = "#36a64f"
|
||||
} else {
|
||||
message = fmt.Sprintf("An alert for *%s* has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
color = "#dd0000"
|
||||
}
|
||||
var formattedConditionResults string
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s - `%s`\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = ":\n> " + alertDescription
|
||||
}
|
||||
body := Body{
|
||||
Text: "",
|
||||
Username: "Gatus",
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Title: "🚨 Gatus Alert",
|
||||
Text: message + description,
|
||||
Color: color,
|
||||
AuthorName: "Gatus",
|
||||
AuthorIcon: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
|
||||
},
|
||||
},
|
||||
}
|
||||
if cfg.Channel != "" {
|
||||
body.Channel = cfg.Channel
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||
Title: "Condition results",
|
||||
Value: formattedConditionResults,
|
||||
Short: false,
|
||||
})
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package rocketchat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-with-channel",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{}},
|
||||
expected: ErrWebhookURLNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["username"] != "Gatus" {
|
||||
t.Errorf("expected username to be 'Gatus', got %v", body["username"])
|
||||
}
|
||||
attachments := body["attachments"].([]interface{})
|
||||
if len(attachments) != 1 {
|
||||
t.Errorf("expected 1 attachment, got %d", len(attachments))
|
||||
}
|
||||
attachment := attachments[0].(map[string]interface{})
|
||||
if attachment["color"] != "#dd0000" {
|
||||
t.Errorf("expected color to be '#dd0000', got %v", attachment["color"])
|
||||
}
|
||||
text := attachment["text"].(string)
|
||||
if !strings.Contains(text, "failed 3 time(s)") {
|
||||
t.Errorf("expected text to contain failure count, got %s", text)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "triggered-with-channel",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc", Channel: "#alerts"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["channel"] != "#alerts" {
|
||||
t.Errorf("expected channel to be '#alerts', got %v", body["channel"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
attachments := body["attachments"].([]interface{})
|
||||
attachment := attachments[0].(map[string]interface{})
|
||||
if attachment["color"] != "#36a64f" {
|
||||
t.Errorf("expected color to be '#36a64f', got %v", attachment["color"])
|
||||
}
|
||||
text := attachment["text"].(string)
|
||||
if !strings.Contains(text, "resolved") {
|
||||
t.Errorf("expected text to contain 'resolved', got %s", text)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://rocketchat.com/hooks/123/abc"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package sendgrid
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
ApiURL = "https://api.sendgrid.com/v3/mail/send"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||
ErrFromNotSet = errors.New("from not set")
|
||||
ErrToNotSet = errors.New("to not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api-key"`
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// ClientConfig is the configuration of the client used to communicate with the provider's target
|
||||
ClientConfig *client.Config `yaml:"client,omitempty"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.APIKey) == 0 {
|
||||
return ErrAPIKeyNotSet
|
||||
}
|
||||
if len(cfg.From) == 0 {
|
||||
return ErrFromNotSet
|
||||
}
|
||||
if len(cfg.To) == 0 {
|
||||
return ErrToNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if override.ClientConfig != nil {
|
||||
cfg.ClientConfig = override.ClientConfig
|
||||
}
|
||||
if len(override.APIKey) > 0 {
|
||||
cfg.APIKey = override.APIKey
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using SendGrid
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
|
||||
payload := provider.buildSendGridPayload(cfg, subject, body)
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request, err := http.NewRequest(http.MethodPost, ApiURL, bytes.NewBuffer(payloadBytes))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", "Bearer "+cfg.APIKey)
|
||||
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to sendgrid alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type SendGridPayload struct {
|
||||
Personalizations []Personalization `json:"personalizations"`
|
||||
From Email `json:"from"`
|
||||
Subject string `json:"subject"`
|
||||
Content []Content `json:"content"`
|
||||
}
|
||||
|
||||
type Personalization struct {
|
||||
To []Email `json:"to"`
|
||||
}
|
||||
|
||||
type Email struct {
|
||||
Email string `json:"email"`
|
||||
}
|
||||
|
||||
type Content struct {
|
||||
Type string `json:"type"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
// buildSendGridPayload builds the SendGrid API payload
|
||||
func (provider *AlertProvider) buildSendGridPayload(cfg *Config, subject, body string) SendGridPayload {
|
||||
toEmails := strings.Split(cfg.To, ",")
|
||||
var recipients []Email
|
||||
for _, email := range toEmails {
|
||||
recipients = append(recipients, Email{Email: strings.TrimSpace(email)})
|
||||
}
|
||||
return SendGridPayload{
|
||||
Personalizations: []Personalization{
|
||||
{
|
||||
To: recipients,
|
||||
},
|
||||
},
|
||||
From: Email{
|
||||
Email: cfg.From,
|
||||
},
|
||||
Subject: subject,
|
||||
Content: []Content{
|
||||
{
|
||||
Type: "text/plain",
|
||||
Value: body,
|
||||
},
|
||||
{
|
||||
Type: "text/html",
|
||||
Value: strings.ReplaceAll(body, "\n", "<br>"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// buildMessageSubjectAndBody builds the message subject and body
|
||||
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
|
||||
var subject, message string
|
||||
if resolved {
|
||||
subject = fmt.Sprintf("[%s] Alert resolved", ep.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
subject = fmt.Sprintf("[%s] Alert triggered", ep.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
var formattedConditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
formattedConditionResults = "\n\nCondition results:\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✅"
|
||||
} else {
|
||||
prefix = "❌"
|
||||
}
|
||||
formattedConditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
var extraLabels string
|
||||
if len(ep.ExtraLabels) > 0 {
|
||||
extraLabels = "\n\nExtra labels:\n"
|
||||
for key, value := range ep.ExtraLabels {
|
||||
extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
|
||||
}
|
||||
}
|
||||
return subject, message + description + extraLabels + formattedConditionResults
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,517 @@
|
||||
package sendgrid
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: "", From: "", To: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "SG.test",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{To: "to@example.com"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider with empty Group should not have been valid")
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err != ErrDuplicateGroupOverride {
|
||||
t.Error("provider with empty Group should return ErrDuplicateGroupOverride")
|
||||
}
|
||||
providerWithDuplicateOverrideGroups := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "SG.test",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{To: "to1@example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
{
|
||||
Config: Config{To: "to2@example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithDuplicateOverrideGroups.Validate(); err == nil {
|
||||
t.Error("provider with duplicate group overrides should not have been valid")
|
||||
}
|
||||
if err := providerWithDuplicateOverrideGroups.Validate(); err != ErrDuplicateGroupOverride {
|
||||
t.Error("provider with duplicate group overrides should return ErrDuplicateGroupOverride")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "SG.test",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{To: "to@example.com"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
providerWithValidMultipleOverrides := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "SG.test",
|
||||
From: "from@example.com",
|
||||
To: "to@example.com",
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{To: "group1@example.com"},
|
||||
Group: "group1",
|
||||
},
|
||||
{
|
||||
Config: Config{To: "group2@example.com"},
|
||||
Group: "group2",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidMultipleOverrides.Validate(); err != nil {
|
||||
t.Error("provider with multiple valid overrides should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusBadRequest, Body: io.NopCloser(strings.NewReader(`{"errors": [{"message": "Invalid API key"}]}`))}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusAccepted, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildSendGridPayload(t *testing.T) {
|
||||
provider := &AlertProvider{}
|
||||
cfg := &Config{
|
||||
From: "test@example.com",
|
||||
To: "to1@example.com,to2@example.com",
|
||||
}
|
||||
subject := "Test Subject"
|
||||
body := "Test Body\nWith new line"
|
||||
payload := provider.buildSendGridPayload(cfg, subject, body)
|
||||
if payload.Subject != subject {
|
||||
t.Errorf("expected subject to be %s, got %s", subject, payload.Subject)
|
||||
}
|
||||
if payload.From.Email != cfg.From {
|
||||
t.Errorf("expected from email to be %s, got %s", cfg.From, payload.From.Email)
|
||||
}
|
||||
if len(payload.Personalizations) != 1 {
|
||||
t.Errorf("expected 1 personalization, got %d", len(payload.Personalizations))
|
||||
}
|
||||
if len(payload.Personalizations[0].To) != 2 {
|
||||
t.Errorf("expected 2 recipients, got %d", len(payload.Personalizations[0].To))
|
||||
}
|
||||
if payload.Personalizations[0].To[0].Email != "to1@example.com" {
|
||||
t.Errorf("expected first recipient to be to1@example.com, got %s", payload.Personalizations[0].To[0].Email)
|
||||
}
|
||||
if payload.Personalizations[0].To[1].Email != "to2@example.com" {
|
||||
t.Errorf("expected second recipient to be to2@example.com, got %s", payload.Personalizations[0].To[1].Email)
|
||||
}
|
||||
if len(payload.Content) != 2 {
|
||||
t.Errorf("expected 2 content types, got %d", len(payload.Content))
|
||||
}
|
||||
if payload.Content[0].Type != "text/plain" {
|
||||
t.Errorf("expected first content type to be text/plain, got %s", payload.Content[0].Type)
|
||||
}
|
||||
if payload.Content[0].Value != body {
|
||||
t.Errorf("expected plain text content to be %s, got %s", body, payload.Content[0].Value)
|
||||
}
|
||||
if payload.Content[1].Type != "text/html" {
|
||||
t.Errorf("expected second content type to be text/html, got %s", payload.Content[1].Type)
|
||||
}
|
||||
expectedHTML := "Test Body<br>With new line"
|
||||
if payload.Content[1].Value != expectedHTML {
|
||||
t.Errorf("expected HTML content to be %s, got %s", expectedHTML, payload.Content[1].Value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildMessageSubjectAndBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
Endpoint *endpoint.Endpoint
|
||||
ExpectedSubject string
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-single-extra-label",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"environment": "production"}},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nExtra labels:\n environment: production\n\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-single-extra-label",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{"service": "api"}},
|
||||
ExpectedSubject: "[endpoint-name] Alert resolved",
|
||||
ExpectedBody: "An alert for endpoint-name has been resolved after passing successfully 5 time(s) in a row\n\nAlert description: description-2\n\nExtra labels:\n service: api\n\n\nCondition results:\n✅ [CONNECTED] == true\n✅ [STATUS] == 200\n",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-extra-labels",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
Endpoint: &endpoint.Endpoint{Name: "endpoint-name", ExtraLabels: map[string]string{}},
|
||||
ExpectedSubject: "[endpoint-name] Alert triggered",
|
||||
ExpectedBody: "An alert for endpoint-name has been triggered due to having failed 3 time(s) in a row\n\nAlert description: description-1\n\nCondition results:\n❌ [CONNECTED] == true\n❌ [STATUS] == 200\n",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
subject, body := scenario.Provider.buildMessageSubjectAndBody(
|
||||
scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if subject != scenario.ExpectedSubject {
|
||||
t.Errorf("expected subject to be %s, got %s", scenario.ExpectedSubject, subject)
|
||||
}
|
||||
if body != scenario.ExpectedBody {
|
||||
t.Errorf("expected body to be %s, got %s", scenario.ExpectedBody, body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{To: "to01@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{To: "group-to@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.test", From: "from@example.com", To: "group-to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{To: "group-to@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "SG.override", "to": "alert-to@example.com", "from": "alert-from@example.com"}},
|
||||
ExpectedOutput: Config{APIKey: "SG.override", From: "alert-from@example.com", To: "alert-to@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-multiple-overrides-pick-correct-group",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{APIKey: "SG.group1", To: "group1@example.com"},
|
||||
},
|
||||
{
|
||||
Group: "group2",
|
||||
Config: Config{APIKey: "SG.group2", From: "group2@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group2",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{APIKey: "SG.group2", From: "group2@example.com", To: "default@example.com"},
|
||||
},
|
||||
{
|
||||
Name: "provider-partial-override-hierarchy",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{APIKey: "SG.default", From: "default@example.com", To: "default@example.com"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{From: "group@example.com"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert@example.com"}},
|
||||
ExpectedOutput: Config{APIKey: "SG.default", From: "group@example.com", To: "alert@example.com"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.APIKey != scenario.ExpectedOutput.APIKey {
|
||||
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if got.To != scenario.ExpectedOutput.To {
|
||||
t.Errorf("expected To to be %s, got %s", scenario.ExpectedOutput.To, got.To)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Config Config
|
||||
ExpectedError error
|
||||
}{
|
||||
{
|
||||
Name: "missing-api-key",
|
||||
Config: Config{APIKey: "", From: "test@example.com", To: "to@example.com"},
|
||||
ExpectedError: ErrAPIKeyNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-from",
|
||||
Config: Config{APIKey: "SG.test", From: "", To: "to@example.com"},
|
||||
ExpectedError: ErrFromNotSet,
|
||||
},
|
||||
{
|
||||
Name: "missing-to",
|
||||
Config: Config{APIKey: "SG.test", From: "test@example.com", To: ""},
|
||||
ExpectedError: ErrToNotSet,
|
||||
},
|
||||
{
|
||||
Name: "valid-config",
|
||||
Config: Config{APIKey: "SG.test", From: "test@example.com", To: "to@example.com"},
|
||||
ExpectedError: nil,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
err := scenario.Config.Validate()
|
||||
if scenario.ExpectedError == nil && err != nil {
|
||||
t.Errorf("expected no error, got %v", err)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err == nil {
|
||||
t.Errorf("expected error %v, got none", scenario.ExpectedError)
|
||||
}
|
||||
if scenario.ExpectedError != nil && err != nil && err.Error() != scenario.ExpectedError.Error() {
|
||||
t.Errorf("expected error %v, got %v", scenario.ExpectedError, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Merge(t *testing.T) {
|
||||
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
|
||||
override := Config{APIKey: "SG.override", To: "override@example.com"}
|
||||
config.Merge(&override)
|
||||
if config.APIKey != "SG.override" {
|
||||
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
|
||||
}
|
||||
if config.From != "from@example.com" {
|
||||
t.Errorf("expected From to remain from@example.com, got %s", config.From)
|
||||
}
|
||||
if config.To != "override@example.com" {
|
||||
t.Errorf("expected To to be override@example.com, got %s", config.To)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_MergeWithClientConfig(t *testing.T) {
|
||||
config := Config{APIKey: "SG.original", From: "from@example.com", To: "to@example.com"}
|
||||
override := Config{APIKey: "SG.override", ClientConfig: &client.Config{Timeout: 30000}}
|
||||
config.Merge(&override)
|
||||
if config.APIKey != "SG.override" {
|
||||
t.Errorf("expected APIKey to be SG.override, got %s", config.APIKey)
|
||||
}
|
||||
if config.ClientConfig == nil {
|
||||
t.Error("expected ClientConfig to be set")
|
||||
}
|
||||
if config.ClientConfig.Timeout != 30000 {
|
||||
t.Errorf("expected ClientConfig.Timeout to be 30000, got %d", config.ClientConfig.Timeout)
|
||||
}
|
||||
config2 := Config{APIKey: "SG.test", From: "from@example.com", To: "to@example.com", ClientConfig: &client.Config{Timeout: 10000}}
|
||||
override2 := Config{APIKey: "SG.override2"}
|
||||
config2.Merge(&override2)
|
||||
if config2.ClientConfig.Timeout != 10000 {
|
||||
t.Errorf("expected ClientConfig.Timeout to remain 10000, got %d", config2.ClientConfig.Timeout)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package signal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrApiURLNotSet = errors.New("api-url not set")
|
||||
ErrNumberNotSet = errors.New("number not set")
|
||||
ErrRecipientsNotSet = errors.New("recipients not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
ApiURL string `yaml:"api-url"` // Signal API URL (e.g., signal-cli-rest-api instance)
|
||||
Number string `yaml:"number"` // Sender phone number
|
||||
Recipients []string `yaml:"recipients"` // List of recipient phone numbers
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.ApiURL) == 0 {
|
||||
return ErrApiURLNotSet
|
||||
}
|
||||
if !strings.HasSuffix(cfg.ApiURL, "/v2/send") {
|
||||
cfg.ApiURL = cfg.ApiURL + "/v2/send"
|
||||
}
|
||||
if len(cfg.Number) == 0 {
|
||||
return ErrNumberNotSet
|
||||
}
|
||||
if len(cfg.Recipients) == 0 {
|
||||
return ErrRecipientsNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.ApiURL) > 0 {
|
||||
cfg.ApiURL = override.ApiURL
|
||||
}
|
||||
if len(override.Number) > 0 {
|
||||
cfg.Number = override.Number
|
||||
}
|
||||
if len(override.Recipients) > 0 {
|
||||
cfg.Recipients = override.Recipients
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Signal
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recipient := range cfg.Recipients {
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved, recipient)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.ApiURL, 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
|
||||
}
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
response.Body.Close()
|
||||
return fmt.Errorf("call to signal alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
response.Body.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Message string `json:"message"`
|
||||
Number string `json:"number"`
|
||||
Recipients []string `json:"recipients"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool, recipient string) ([]byte, error) {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("🟢 RESOLVED: %s\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("🔴 ALERT: %s\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += fmt.Sprintf("\n\nDescription: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
message += "\n\nCondition results:"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
message += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
Message: message,
|
||||
Number: cfg.Number,
|
||||
Recipients: []string{recipient},
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package signal
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-api-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||
expected: ErrApiURLNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-number",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Recipients: []string{"+0987654321"}}},
|
||||
expected: ErrNumberNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-recipients",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890"}},
|
||||
expected: ErrRecipientsNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321", "+1111111111"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.URL.Path != "/v2/send" {
|
||||
t.Errorf("expected path /v2/send, got %s", r.URL.Path)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["number"] != "+1234567890" {
|
||||
t.Errorf("expected number to be '+1234567890', got %v", body["number"])
|
||||
}
|
||||
recipients := body["recipients"].([]interface{})
|
||||
if len(recipients) != 1 {
|
||||
t.Errorf("expected 1 recipient per request, got %d", len(recipients))
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "ALERT") {
|
||||
t.Errorf("expected message to contain 'ALERT', got %s", message)
|
||||
}
|
||||
if !strings.Contains(message, "failed 3 time(s)") {
|
||||
t.Errorf("expected message to contain failure count, got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "RESOLVED") {
|
||||
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{ApiURL: "http://localhost:8080", Number: "+1234567890", Recipients: []string{"+0987654321"}}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package signl4
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTeamSecretNotSet = errors.New("team-secret not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TeamSecret string `yaml:"team-secret"` // SIGNL4 team secret
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.TeamSecret) == 0 {
|
||||
return ErrTeamSecretNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.TeamSecret) > 0 {
|
||||
cfg.TeamSecret = override.TeamSecret
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using SIGNL4
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
webhookURL := fmt.Sprintf("https://connect.signl4.com/webhook/%s", cfg.TeamSecret)
|
||||
request, err := http.NewRequest(http.MethodPost, webhookURL, 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 >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to signl4 alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Title string `json:"Title"`
|
||||
Message string `json:"Message"`
|
||||
XS4Service string `json:"X-S4-Service"`
|
||||
XS4Status string `json:"X-S4-Status"`
|
||||
XS4ExternalID string `json:"X-S4-ExternalID"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var title, message, status string
|
||||
if resolved {
|
||||
title = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
status = "resolved"
|
||||
} else {
|
||||
title = fmt.Sprintf("TRIGGERED: %s", ep.DisplayName())
|
||||
message = fmt.Sprintf("An alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
status = "new"
|
||||
}
|
||||
var conditionResults string
|
||||
if len(result.ConditionResults) > 0 {
|
||||
conditionResults = "\n\nCondition results:\n"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var prefix string
|
||||
if conditionResult.Success {
|
||||
prefix = "✓"
|
||||
} else {
|
||||
prefix = "✗"
|
||||
}
|
||||
conditionResults += fmt.Sprintf("%s %s\n", prefix, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += "\n\nDescription: " + alertDescription
|
||||
}
|
||||
message += conditionResults
|
||||
body := Body{
|
||||
Title: title,
|
||||
Message: message,
|
||||
XS4Service: ep.DisplayName(),
|
||||
XS4Status: status,
|
||||
XS4ExternalID: fmt.Sprintf("gatus-%s", ep.Key()),
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
package signl4
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
invalidProvider := AlertProvider{DefaultConfig: Config{TeamSecret: ""}}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
|
||||
providerWithInvalidOverrideGroup := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{TeamSecret: "team-secret-123"},
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
|
||||
t.Error("provider Group shouldn't have been valid")
|
||||
}
|
||||
providerWithInvalidOverrideTo := AlertProvider{
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{TeamSecret: ""},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
|
||||
t.Error("provider team secret shouldn't have been valid")
|
||||
}
|
||||
providerWithValidOverride := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Config: Config{TeamSecret: "team-secret-override"},
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithValidOverride.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "resolved-error",
|
||||
Provider: AlertProvider{DefaultConfig: Config{TeamSecret: "team-secret-123"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Endpoint endpoint.Endpoint
|
||||
Alert alert.Alert
|
||||
NoConditions bool
|
||||
Resolved bool
|
||||
ExpectedBody string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"Title\":\"TRIGGERED: group/name\",\"Message\":\"An alert for group/name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\\n\\nCondition results:\\n✗ [CONNECTED] == true\\n✗ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"Title\":\"TRIGGERED: name\",\"Message\":\"An alert for name has been triggered due to having failed 3 time(s) in a row\\n\\nDescription: description-1\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"new\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"Title\":\"RESOLVED: name\",\"Message\":\"An alert for name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-_name\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"Title\":\"RESOLVED: group/name\",\"Message\":\"An alert for group/name has been resolved after passing successfully 5 time(s) in a row\\n\\nDescription: description-2\\n\\nCondition results:\\n✓ [CONNECTED] == true\\n✓ [STATUS] == 200\\n\",\"X-S4-Service\":\"group/name\",\"X-S4-Status\":\"resolved\",\"X-S4-ExternalID\":\"gatus-group_name\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
var conditionResults []*endpoint.ConditionResult
|
||||
if !scenario.NoConditions {
|
||||
conditionResults = []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
body, err := scenario.Provider.buildRequestBody(
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: conditionResults,
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("buildRequestBody returned an error: %v", err)
|
||||
}
|
||||
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())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||
},
|
||||
{
|
||||
Name: "provider-no-override-specify-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: nil,
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-no-group-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{TeamSecret: "team-secret-override"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-123"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-override-specify-group-should-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{TeamSecret: "team-secret-override"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-override"},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group",
|
||||
Config: Config{TeamSecret: "team-secret-override"},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{"team-secret": "team-secret-alert"}},
|
||||
ExpectedOutput: Config{TeamSecret: "team-secret-alert"},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if got.TeamSecret != scenario.ExpectedOutput.TeamSecret {
|
||||
t.Errorf("expected team secret to be %s, got %s", scenario.ExpectedOutput.TeamSecret, got.TeamSecret)
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfigWithInvalidAlertOverride(t *testing.T) {
|
||||
// Test case 1: Empty override should be ignored, default config should be used
|
||||
provider := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
}
|
||||
alertWithEmptyOverride := alert.Alert{
|
||||
ProviderOverride: map[string]any{"team-secret": ""},
|
||||
}
|
||||
cfg, err := provider.GetConfig("", &alertWithEmptyOverride)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if cfg.TeamSecret != "team-secret-123" {
|
||||
t.Errorf("expected team secret to remain default 'team-secret-123', got %s", cfg.TeamSecret)
|
||||
}
|
||||
|
||||
// Test case 2: Invalid default config with no valid override should fail
|
||||
providerWithInvalidDefault := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: ""},
|
||||
}
|
||||
alertWithEmptyOverride2 := alert.Alert{
|
||||
ProviderOverride: map[string]any{"team-secret": ""},
|
||||
}
|
||||
_, err = providerWithInvalidDefault.GetConfig("", &alertWithEmptyOverride2)
|
||||
if err == nil {
|
||||
t.Error("expected error due to invalid default config, got none")
|
||||
}
|
||||
if err != ErrTeamSecretNotSet {
|
||||
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateWithDuplicateGroupOverride(t *testing.T) {
|
||||
providerWithDuplicateOverride := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: "team-secret-123"},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{TeamSecret: "team-secret-override-1"},
|
||||
},
|
||||
{
|
||||
Group: "group1",
|
||||
Config: Config{TeamSecret: "team-secret-override-2"},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := providerWithDuplicateOverride.Validate(); err == nil {
|
||||
t.Error("provider should not have been valid due to duplicate group override")
|
||||
}
|
||||
if err := providerWithDuplicateOverride.Validate(); err != ErrDuplicateGroupOverride {
|
||||
t.Errorf("expected ErrDuplicateGroupOverride, got %v", providerWithDuplicateOverride.Validate())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_ValidateOverridesWithInvalidAlert(t *testing.T) {
|
||||
provider := AlertProvider{
|
||||
DefaultConfig: Config{TeamSecret: ""},
|
||||
}
|
||||
alertWithEmptyOverride := alert.Alert{
|
||||
ProviderOverride: map[string]any{"team-secret": ""},
|
||||
}
|
||||
err := provider.ValidateOverrides("", &alertWithEmptyOverride)
|
||||
if err == nil {
|
||||
t.Error("expected error due to invalid default config, got none")
|
||||
}
|
||||
if err != ErrTeamSecretNotSet {
|
||||
t.Errorf("expected ErrTeamSecretNotSet, got %v", err)
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,8 @@ var (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL
|
||||
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -34,6 +35,9 @@ func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
if len(override.Title) > 0 {
|
||||
cfg.Title = override.Title
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Slack
|
||||
@@ -73,7 +77,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
|
||||
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -111,7 +115,7 @@ type Field 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(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
|
||||
var message, color string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
@@ -138,13 +142,16 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
|
||||
Text: "",
|
||||
Attachments: []Attachment{
|
||||
{
|
||||
Title: ":helmet_with_white_cross: Gatus",
|
||||
Title: cfg.Title,
|
||||
Text: message + description,
|
||||
Short: false,
|
||||
Color: color,
|
||||
},
|
||||
},
|
||||
}
|
||||
if len(body.Attachments[0].Title) == 0 {
|
||||
body.Attachments[0].Title = ":helmet_with_white_cross: Gatus"
|
||||
}
|
||||
if len(formattedConditionResults) > 0 {
|
||||
body.Attachments[0].Fields = append(body.Attachments[0].Fields, Field{
|
||||
Title: "Condition results",
|
||||
|
||||
@@ -150,7 +150,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -158,7 +158,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{
|
||||
Name: "triggered-with-no-conditions",
|
||||
NoConditions: true,
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
@@ -175,7 +175,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
@@ -183,12 +183,20 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group",
|
||||
Provider: AlertProvider{},
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\":helmet_with_white_cross: Gatus\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-group-and-custom-title",
|
||||
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: "custom title"}},
|
||||
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"text\":\"\",\"attachments\":[{\"title\":\"custom title\",\"text\":\"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row:\\n\\u003e description-2\",\"short\":false,\"color\":\"#36A64F\",\"fields\":[{\"title\":\"Condition results\",\"value\":\":white_check_mark: - `[CONNECTED] == true`\\n:white_check_mark: - `[STATUS] == 200`\\n\",\"short\":false}]}]}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
@@ -199,7 +207,12 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
}
|
||||
}
|
||||
cfg, err := scenario.Provider.GetConfig(scenario.Endpoint.Group, &scenario.Alert)
|
||||
if err != nil {
|
||||
t.Fatal("couldn't get config:", err.Error())
|
||||
}
|
||||
body := scenario.Provider.buildRequestBody(
|
||||
cfg,
|
||||
&scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
package splunk
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrHecURLNotSet = errors.New("hec-url not set")
|
||||
ErrHecTokenNotSet = errors.New("hec-token not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
HecURL string `yaml:"hec-url"` // Splunk HEC (HTTP Event Collector) URL
|
||||
HecToken string `yaml:"hec-token"` // Splunk HEC token
|
||||
Source string `yaml:"source,omitempty"` // Event source
|
||||
SourceType string `yaml:"sourcetype,omitempty"` // Event source type
|
||||
Index string `yaml:"index,omitempty"` // Splunk index
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.HecURL) == 0 {
|
||||
return ErrHecURLNotSet
|
||||
}
|
||||
if len(cfg.HecToken) == 0 {
|
||||
return ErrHecTokenNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.HecURL) > 0 {
|
||||
cfg.HecURL = override.HecURL
|
||||
}
|
||||
if len(override.HecToken) > 0 {
|
||||
cfg.HecToken = override.HecToken
|
||||
}
|
||||
if len(override.Source) > 0 {
|
||||
cfg.Source = override.Source
|
||||
}
|
||||
if len(override.SourceType) > 0 {
|
||||
cfg.SourceType = override.SourceType
|
||||
}
|
||||
if len(override.Index) > 0 {
|
||||
cfg.Index = override.Index
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Splunk
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(cfg, ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/services/collector/event", cfg.HecURL), buffer)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
request.Header.Set("Authorization", fmt.Sprintf("Splunk %s", cfg.HecToken))
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to splunk alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Time int64 `json:"time"`
|
||||
Source string `json:"source,omitempty"`
|
||||
SourceType string `json:"sourcetype,omitempty"`
|
||||
Index string `json:"index,omitempty"`
|
||||
Event Event `json:"event"`
|
||||
}
|
||||
|
||||
type Event struct {
|
||||
AlertType string `json:"alert_type"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Conditions []*endpoint.ConditionResult `json:"conditions,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var alertType, status, message string
|
||||
if resolved {
|
||||
alertType = "resolved"
|
||||
status = "ok"
|
||||
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
alertType = "triggered"
|
||||
status = "critical"
|
||||
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
event := Event{
|
||||
AlertType: alertType,
|
||||
Endpoint: ep.DisplayName(),
|
||||
Group: ep.Group,
|
||||
Status: status,
|
||||
Message: message,
|
||||
Description: alert.GetDescription(),
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
event.Conditions = result.ConditionResults
|
||||
}
|
||||
body := Body{
|
||||
Time: time.Now().Unix(),
|
||||
Event: event,
|
||||
}
|
||||
// Set optional fields
|
||||
if cfg.Source != "" {
|
||||
body.Source = cfg.Source
|
||||
} else {
|
||||
body.Source = "gatus"
|
||||
}
|
||||
if cfg.SourceType != "" {
|
||||
body.SourceType = cfg.SourceType
|
||||
} else {
|
||||
body.SourceType = "gatus:alert"
|
||||
}
|
||||
if cfg.Index != "" {
|
||||
body.Index = cfg.Index
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package splunk
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-with-index",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-hec-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecToken: "token123"}},
|
||||
expected: ErrHecURLNotSet,
|
||||
},
|
||||
{
|
||||
name: "invalid-hec-token",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088"}},
|
||||
expected: ErrHecTokenNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.URL.Path != "/services/collector/event" {
|
||||
t.Errorf("expected path /services/collector/event, got %s", r.URL.Path)
|
||||
}
|
||||
if r.Header.Get("Authorization") != "Splunk token123" {
|
||||
t.Errorf("expected Authorization header to be 'Splunk token123', got %s", r.Header.Get("Authorization"))
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["time"] == nil {
|
||||
t.Error("expected 'time' field in request body")
|
||||
}
|
||||
event := body["event"].(map[string]interface{})
|
||||
if event["alert_type"] != "triggered" {
|
||||
t.Errorf("expected alert_type to be 'triggered', got %v", event["alert_type"])
|
||||
}
|
||||
if event["status"] != "critical" {
|
||||
t.Errorf("expected status to be 'critical', got %v", event["status"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123", Index: "main"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["index"] != "main" {
|
||||
t.Errorf("expected index to be 'main', got %v", body["index"])
|
||||
}
|
||||
event := body["event"].(map[string]interface{})
|
||||
if event["alert_type"] != "resolved" {
|
||||
t.Errorf("expected alert_type to be 'resolved', got %v", event["alert_type"])
|
||||
}
|
||||
if event["status"] != "ok" {
|
||||
t.Errorf("expected status to be 'ok', got %v", event["status"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{HecURL: "https://splunk.example.com:8088", HecToken: "token123"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusForbidden, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package squadcast
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Squadcast webhook URL
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Squadcast
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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 >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to squadcast alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
EventID string `json:"event_id"`
|
||||
Status string `json:"status"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var message, status string
|
||||
eventID := fmt.Sprintf("gatus-%s", ep.Key())
|
||||
if resolved {
|
||||
message = fmt.Sprintf("RESOLVED: %s", ep.DisplayName())
|
||||
status = "resolve"
|
||||
} else {
|
||||
message = fmt.Sprintf("ALERT: %s", ep.DisplayName())
|
||||
status = "trigger"
|
||||
}
|
||||
description := fmt.Sprintf("Endpoint: %s\n", ep.DisplayName())
|
||||
if resolved {
|
||||
description += fmt.Sprintf("Alert has been resolved after passing successfully %d time(s) in a row\n", alert.SuccessThreshold)
|
||||
} else {
|
||||
description += fmt.Sprintf("Endpoint has failed %d time(s) in a row\n", alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description += fmt.Sprintf("\nDescription: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
description += "\n\nCondition Results:"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
description += fmt.Sprintf("\n%s %s", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
Message: message,
|
||||
Description: description,
|
||||
EventID: eventID,
|
||||
Status: status,
|
||||
Tags: map[string]string{
|
||||
"endpoint": ep.Name,
|
||||
"group": ep.Group,
|
||||
"source": "gatus",
|
||||
},
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package squadcast
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{}},
|
||||
expected: ErrWebhookURLNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["status"] != "trigger" {
|
||||
t.Errorf("expected status to be 'trigger', got %v", body["status"])
|
||||
}
|
||||
if body["event_id"] == nil {
|
||||
t.Error("expected 'event_id' field in request body")
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "ALERT") {
|
||||
t.Errorf("expected message to contain 'ALERT', got %s", message)
|
||||
}
|
||||
description := body["description"].(string)
|
||||
if !strings.Contains(description, "failed 3 time(s)") {
|
||||
t.Errorf("expected description to contain failure count, got %s", description)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["status"] != "resolve" {
|
||||
t.Errorf("expected status to be 'resolve', got %v", body["status"])
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "RESOLVED") {
|
||||
t.Errorf("expected message to contain 'RESOLVED', got %s", message)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://api.squadcast.com/v3/incidents/api/abcd1234"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,10 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
Value: conditionResult.Condition,
|
||||
})
|
||||
}
|
||||
|
||||
var description string
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "**Description**: " + alertDescription
|
||||
}
|
||||
cardContent := AdaptiveCardBody{
|
||||
Type: "AdaptiveCard",
|
||||
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
|
||||
@@ -190,6 +193,11 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
Text: message,
|
||||
Wrap: true,
|
||||
},
|
||||
{
|
||||
Type: "TextBlock",
|
||||
Text: description,
|
||||
Wrap: true,
|
||||
},
|
||||
{
|
||||
Type: "FactSet",
|
||||
Facts: facts,
|
||||
|
||||
@@ -152,14 +152,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-1\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-no-conditions",
|
||||
@@ -167,7 +167,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"TextBlock\",\"text\":\"**Description**: description-2\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultApiUrl = "https://api.telegram.org"
|
||||
const ApiURL = "https://api.telegram.org"
|
||||
|
||||
var (
|
||||
ErrTokenNotSet = errors.New("token not set")
|
||||
@@ -33,7 +33,7 @@ type Config struct {
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.ApiUrl) == 0 {
|
||||
cfg.ApiUrl = defaultApiUrl
|
||||
cfg.ApiUrl = ApiURL
|
||||
}
|
||||
if len(cfg.Token) == 0 {
|
||||
return ErrTokenNotSet
|
||||
|
||||
@@ -29,8 +29,10 @@ type Config struct {
|
||||
From string `yaml:"from"`
|
||||
To string `yaml:"to"`
|
||||
|
||||
// TODO in v6.0.0: Rename this to text-triggered
|
||||
TextTwilioTriggered string `yaml:"text-twilio-triggered,omitempty"` // String used in the SMS body and subject (optional)
|
||||
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
|
||||
// TODO in v6.0.0: Rename this to text-resolved
|
||||
TextTwilioResolved string `yaml:"text-twilio-resolved,omitempty"` // String used in the SMS body and subject (optional)
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
@@ -113,13 +115,23 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
|
||||
var message string
|
||||
if resolved {
|
||||
if len(cfg.TextTwilioResolved) > 0 {
|
||||
message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
|
||||
// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
|
||||
message = cfg.TextTwilioResolved
|
||||
message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
|
||||
message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
|
||||
message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
|
||||
message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
|
||||
} else {
|
||||
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
} else {
|
||||
if len(cfg.TextTwilioTriggered) > 0 {
|
||||
message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
|
||||
// Support both old {endpoint}/{description} and new [ENDPOINT]/[ALERT_DESCRIPTION] formats
|
||||
message = cfg.TextTwilioTriggered
|
||||
message = strings.Replace(message, "{endpoint}", ep.DisplayName(), 1)
|
||||
message = strings.Replace(message, "{description}", alert.GetDescription(), 1)
|
||||
message = strings.Replace(message, "[ENDPOINT]", ep.DisplayName(), 1)
|
||||
message = strings.Replace(message, "[ALERT_DESCRIPTION]", alert.GetDescription(), 1)
|
||||
} else {
|
||||
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
|
||||
@@ -129,6 +129,27 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Resolved: true,
|
||||
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-old-placeholders",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: {endpoint} - {description}"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "triggered-with-new-placeholders",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioTriggered: "Alert: [ENDPOINT] - [ALERT_DESCRIPTION]"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedBody: "Body=Alert%3A+endpoint-name+-+description-1&From=3&To=4",
|
||||
},
|
||||
{
|
||||
Name: "resolved-with-mixed-placeholders",
|
||||
Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4", TextTwilioResolved: "Resolved: {endpoint} and [ENDPOINT] - {description} and [ALERT_DESCRIPTION]"}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedBody: "Body=Resolved%3A+endpoint-name+and+endpoint-name+-+description-2+and+description-2&From=3&To=4",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
package vonage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const ApiURL = "https://rest.nexmo.com/sms/json"
|
||||
|
||||
var (
|
||||
ErrAPIKeyNotSet = errors.New("api-key not set")
|
||||
ErrAPISecretNotSet = errors.New("api-secret not set")
|
||||
ErrFromNotSet = errors.New("from not set")
|
||||
ErrToNotSet = errors.New("to not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
APIKey string `yaml:"api-key"`
|
||||
APISecret string `yaml:"api-secret"`
|
||||
From string `yaml:"from"`
|
||||
To []string `yaml:"to"`
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.APIKey) == 0 {
|
||||
return ErrAPIKeyNotSet
|
||||
}
|
||||
if len(cfg.APISecret) == 0 {
|
||||
return ErrAPISecretNotSet
|
||||
}
|
||||
if len(cfg.From) == 0 {
|
||||
return ErrFromNotSet
|
||||
}
|
||||
if len(cfg.To) == 0 {
|
||||
return ErrToNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.APIKey) > 0 {
|
||||
cfg.APIKey = override.APIKey
|
||||
}
|
||||
if len(override.APISecret) > 0 {
|
||||
cfg.APISecret = override.APISecret
|
||||
}
|
||||
if len(override.From) > 0 {
|
||||
cfg.From = override.From
|
||||
}
|
||||
if len(override.To) > 0 {
|
||||
cfg.To = override.To
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Vonage
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
message := provider.buildMessage(cfg, ep, alert, result, resolved)
|
||||
|
||||
// Send SMS to each recipient
|
||||
for _, recipient := range cfg.To {
|
||||
if err := provider.sendSMS(cfg, recipient, message); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendSMS sends an individual SMS message
|
||||
func (provider *AlertProvider) sendSMS(cfg *Config, to, message string) error {
|
||||
data := url.Values{}
|
||||
data.Set("api_key", cfg.APIKey)
|
||||
data.Set("api_secret", cfg.APISecret)
|
||||
data.Set("from", cfg.From)
|
||||
data.Set("to", to)
|
||||
data.Set("text", message)
|
||||
request, err := http.NewRequest(http.MethodPost, ApiURL, strings.NewReader(data.Encode()))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response, err := client.GetHTTPClient(nil).Do(request)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer response.Body.Close()
|
||||
// Read response body once and use it for both error handling and JSON processing
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if response.StatusCode >= 400 {
|
||||
return fmt.Errorf("call to vonage alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
// Check response for errors in messages array
|
||||
var vonageResponse Response
|
||||
if err := json.Unmarshal(body, &vonageResponse); err != nil {
|
||||
return err
|
||||
}
|
||||
// Check if any message failed
|
||||
for _, msg := range vonageResponse.Messages {
|
||||
if msg.Status != "0" {
|
||||
return fmt.Errorf("vonage SMS failed with status %s: %s", msg.Status, msg.ErrorText)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
MessageCount string `json:"message-count"`
|
||||
Messages []Message `json:"messages"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
To string `json:"to"`
|
||||
MessageID string `json:"message-id"`
|
||||
Status string `json:"status"`
|
||||
ErrorText string `json:"error-text"`
|
||||
RemainingBalance string `json:"remaining-balance"`
|
||||
MessagePrice string `json:"message-price"`
|
||||
Network string `json:"network"`
|
||||
}
|
||||
|
||||
// buildMessage builds the SMS message content
|
||||
func (provider *AlertProvider) buildMessage(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
|
||||
if resolved {
|
||||
return fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
} else {
|
||||
return fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
|
||||
}
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
package vonage
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestVonageAlertProvider_IsValid(t *testing.T) {
|
||||
invalidProvider := AlertProvider{}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
validProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsValidWithOverride(t *testing.T) {
|
||||
validProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "override-key",
|
||||
APISecret: "override-secret",
|
||||
From: "Override",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := validProvider.Validate(); err != nil {
|
||||
t.Error("provider should've been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsNotValidWithInvalidOverrideGroup(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "",
|
||||
Config: Config{
|
||||
APIKey: "override-key",
|
||||
APISecret: "override-secret",
|
||||
From: "Override",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsNotValidWithDuplicateOverrideGroup(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "override-key1",
|
||||
APISecret: "override-secret1",
|
||||
From: "Override1",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "override-key2",
|
||||
APISecret: "override-secret2",
|
||||
From: "Override2",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsValidWithInvalidFrom(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVonageAlertProvider_IsValidWithInvalidTo(t *testing.T) {
|
||||
invalidProvider := AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{},
|
||||
},
|
||||
}
|
||||
if err := invalidProvider.Validate(); err == nil {
|
||||
t.Error("provider shouldn't have been valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
MockRoundTripper test.MockRoundTripper
|
||||
ExpectedError bool
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.50","message-price":"0.10","network":"12345"}]}`)),
|
||||
}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error-status-code",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusInternalServerError, Body: http.NoBody}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "triggered-error-vonage-response",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"","status":"2","error-text":"Missing from param"}]}`)),
|
||||
}
|
||||
}),
|
||||
ExpectedError: true,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.40","message-price":"0.10","network":"12345"}]}`)),
|
||||
}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
{
|
||||
Name: "multiple-recipients",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890", "+0987654321"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(`{"message-count":"1","messages":[{"to":"+1234567890","message-id":"test-id","status":"0","remaining-balance":"10.30","message-price":"0.10","network":"12345"}]}`)),
|
||||
}
|
||||
}),
|
||||
ExpectedError: false,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
|
||||
err := scenario.Provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if scenario.ExpectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.ExpectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_buildMessage(t *testing.T) {
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
ExpectedMessage string
|
||||
}{
|
||||
{
|
||||
Name: "triggered",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: false,
|
||||
ExpectedMessage: "TRIGGERED: endpoint-name - description-1",
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
Resolved: true,
|
||||
ExpectedMessage: "RESOLVED: endpoint-name - description-2",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
message := scenario.Provider.buildMessage(
|
||||
&scenario.Provider.DefaultConfig,
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
|
||||
},
|
||||
},
|
||||
scenario.Resolved,
|
||||
)
|
||||
if message != scenario.ExpectedMessage {
|
||||
t.Errorf("expected %s, got %s", scenario.ExpectedMessage, message)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetConfig(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
Name string
|
||||
Provider AlertProvider
|
||||
InputGroup string
|
||||
InputAlert alert.Alert
|
||||
ExpectedOutput Config
|
||||
}{
|
||||
{
|
||||
Name: "provider-no-override-should-default",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "group-override-key",
|
||||
APISecret: "group-override-secret",
|
||||
From: "GroupOverride",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "group-override-key",
|
||||
APISecret: "group-override-secret",
|
||||
From: "GroupOverride",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-partial",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
InputGroup: "",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"api-key": "override-key",
|
||||
"api-secret": "override-secret",
|
||||
"from": "Override",
|
||||
"to": []string{"+9876543210"},
|
||||
}},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "override-key",
|
||||
APISecret: "override-secret",
|
||||
From: "Override",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-both-group-and-alert-override",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "test-group",
|
||||
Config: Config{
|
||||
APIKey: "group-override-key",
|
||||
From: "GroupOverride",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{ProviderOverride: map[string]any{
|
||||
"api-secret": "alert-override-secret",
|
||||
"to": []string{"+9876543210"},
|
||||
}},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "group-override-key",
|
||||
APISecret: "alert-override-secret",
|
||||
From: "GroupOverride",
|
||||
To: []string{"+9876543210"},
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "provider-with-group-override-no-match",
|
||||
Provider: AlertProvider{
|
||||
DefaultConfig: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
Overrides: []Override{
|
||||
{
|
||||
Group: "different-group",
|
||||
Config: Config{
|
||||
APIKey: "group-override-key",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
InputGroup: "test-group",
|
||||
InputAlert: alert.Alert{},
|
||||
ExpectedOutput: Config{
|
||||
APIKey: "test-key",
|
||||
APISecret: "test-secret",
|
||||
From: "Gatus",
|
||||
To: []string{"+1234567890"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
|
||||
if err != nil {
|
||||
t.Error("expected no error, got:", err.Error())
|
||||
}
|
||||
if got.APIKey != scenario.ExpectedOutput.APIKey {
|
||||
t.Errorf("expected APIKey to be %s, got %s", scenario.ExpectedOutput.APIKey, got.APIKey)
|
||||
}
|
||||
if got.APISecret != scenario.ExpectedOutput.APISecret {
|
||||
t.Errorf("expected APISecret to be %s, got %s", scenario.ExpectedOutput.APISecret, got.APISecret)
|
||||
}
|
||||
if got.From != scenario.ExpectedOutput.From {
|
||||
t.Errorf("expected From to be %s, got %s", scenario.ExpectedOutput.From, got.From)
|
||||
}
|
||||
if len(got.To) != len(scenario.ExpectedOutput.To) {
|
||||
t.Errorf("expected To to have length %d, got %d", len(scenario.ExpectedOutput.To), len(got.To))
|
||||
} else {
|
||||
for i, to := range got.To {
|
||||
if to != scenario.ExpectedOutput.To[i] {
|
||||
t.Errorf("expected To[%d] to be %s, got %s", i, scenario.ExpectedOutput.To[i], to)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Test ValidateOverrides as well, since it really just calls GetConfig
|
||||
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
|
||||
t.Errorf("unexpected error: %s", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
package webex
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Webex Teams webhook URL
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Webex
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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 >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to webex alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
RoomID string `json:"roomId,omitempty"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Markdown string `json:"markdown"`
|
||||
}
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var message string
|
||||
if resolved {
|
||||
message = fmt.Sprintf("✅ **RESOLVED**: %s\n\nAlert has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
} else {
|
||||
message = fmt.Sprintf("🚨 **ALERT**: %s\n\nEndpoint has failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
}
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
message += fmt.Sprintf("\n\n**Description**: %s", alertDescription)
|
||||
}
|
||||
if len(result.ConditionResults) > 0 {
|
||||
message += "\n\n**Condition Results:**"
|
||||
for _, conditionResult := range result.ConditionResults {
|
||||
var status string
|
||||
if conditionResult.Success {
|
||||
status = "✅"
|
||||
} else {
|
||||
status = "❌"
|
||||
}
|
||||
message += fmt.Sprintf("\n- %s `%s`", status, conditionResult.Condition)
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
Markdown: message,
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package webex
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{}},
|
||||
expected: ErrWebhookURLNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["markdown"] == nil {
|
||||
t.Error("expected 'markdown' field in request body")
|
||||
}
|
||||
markdown := body["markdown"].(string)
|
||||
if !strings.Contains(markdown, "ALERT") {
|
||||
t.Errorf("expected markdown to contain 'ALERT', got %s", markdown)
|
||||
}
|
||||
if !strings.Contains(markdown, "failed 3 time(s)") {
|
||||
t.Errorf("expected markdown to contain failure count, got %s", markdown)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
markdown := body["markdown"].(string)
|
||||
if !strings.Contains(markdown, "RESOLVED") {
|
||||
t.Errorf("expected markdown to contain 'RESOLVED', got %s", markdown)
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://webexapis.com/v1/webhooks/incoming/123"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package zapier
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrWebhookURLNotSet = errors.New("webhook-url not set")
|
||||
ErrDuplicateGroupOverride = errors.New("duplicate group override")
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
WebhookURL string `yaml:"webhook-url"` // Zapier webhook URL
|
||||
}
|
||||
|
||||
func (cfg *Config) Validate() error {
|
||||
if len(cfg.WebhookURL) == 0 {
|
||||
return ErrWebhookURLNotSet
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cfg *Config) Merge(override *Config) {
|
||||
if len(override.WebhookURL) > 0 {
|
||||
cfg.WebhookURL = override.WebhookURL
|
||||
}
|
||||
}
|
||||
|
||||
// AlertProvider is the configuration necessary for sending an alert using Zapier
|
||||
type AlertProvider struct {
|
||||
DefaultConfig Config `yaml:",inline"`
|
||||
|
||||
// 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"`
|
||||
Config `yaml:",inline"`
|
||||
}
|
||||
|
||||
// Validate the provider's configuration
|
||||
func (provider *AlertProvider) Validate() error {
|
||||
registeredGroups := make(map[string]bool)
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
|
||||
return ErrDuplicateGroupOverride
|
||||
}
|
||||
registeredGroups[override.Group] = true
|
||||
}
|
||||
}
|
||||
return provider.DefaultConfig.Validate()
|
||||
}
|
||||
|
||||
// Send an alert using the provider
|
||||
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
|
||||
cfg, err := provider.GetConfig(ep.Group, alert)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body, err := provider.buildRequestBody(ep, alert, result, resolved)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
buffer := bytes.NewBuffer(body)
|
||||
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, 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 >= 400 {
|
||||
body, _ := io.ReadAll(response.Body)
|
||||
return fmt.Errorf("call to zapier alert returned status code %d: %s", response.StatusCode, string(body))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type Body struct {
|
||||
AlertType string `json:"alert_type"`
|
||||
Status string `json:"status"`
|
||||
Endpoint string `json:"endpoint"`
|
||||
Group string `json:"group,omitempty"`
|
||||
Message string `json:"message"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Timestamp string `json:"timestamp"`
|
||||
SuccessThreshold int `json:"success_threshold,omitempty"`
|
||||
FailureThreshold int `json:"failure_threshold,omitempty"`
|
||||
ConditionResults []*endpoint.ConditionResult `json:"condition_results,omitempty"`
|
||||
TotalConditions int `json:"total_conditions"`
|
||||
PassedConditions int `json:"passed_conditions"`
|
||||
FailedConditions int `json:"failed_conditions"`
|
||||
}
|
||||
|
||||
|
||||
// buildRequestBody builds the request body for the provider
|
||||
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) ([]byte, error) {
|
||||
var alertType, status, message string
|
||||
var successThreshold, failureThreshold int
|
||||
if resolved {
|
||||
alertType = "resolved"
|
||||
status = "ok"
|
||||
message = fmt.Sprintf("Alert for %s has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
|
||||
successThreshold = alert.SuccessThreshold
|
||||
} else {
|
||||
alertType = "triggered"
|
||||
status = "critical"
|
||||
message = fmt.Sprintf("Alert for %s has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
|
||||
failureThreshold = alert.FailureThreshold
|
||||
}
|
||||
// Process condition results
|
||||
passedConditions := 0
|
||||
failedConditions := 0
|
||||
for _, cr := range result.ConditionResults {
|
||||
if cr.Success {
|
||||
passedConditions++
|
||||
} else {
|
||||
failedConditions++
|
||||
}
|
||||
}
|
||||
body := Body{
|
||||
AlertType: alertType,
|
||||
Status: status,
|
||||
Endpoint: ep.DisplayName(),
|
||||
Group: ep.Group,
|
||||
Message: message,
|
||||
Description: alert.GetDescription(),
|
||||
Timestamp: time.Now().Format(time.RFC3339),
|
||||
SuccessThreshold: successThreshold,
|
||||
FailureThreshold: failureThreshold,
|
||||
ConditionResults: result.ConditionResults,
|
||||
TotalConditions: len(result.ConditionResults),
|
||||
PassedConditions: passedConditions,
|
||||
FailedConditions: failedConditions,
|
||||
}
|
||||
bodyAsJSON, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return bodyAsJSON, nil
|
||||
}
|
||||
|
||||
// GetDefaultAlert returns the provider's default alert configuration
|
||||
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
|
||||
return provider.DefaultAlert
|
||||
}
|
||||
|
||||
// GetConfig returns the configuration for the provider with the overrides applied
|
||||
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
|
||||
cfg := provider.DefaultConfig
|
||||
// Handle group overrides
|
||||
if provider.Overrides != nil {
|
||||
for _, override := range provider.Overrides {
|
||||
if group == override.Group {
|
||||
cfg.Merge(&override.Config)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle alert overrides
|
||||
if len(alert.ProviderOverride) != 0 {
|
||||
overrideConfig := Config{}
|
||||
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cfg.Merge(&overrideConfig)
|
||||
}
|
||||
// Validate the configuration
|
||||
err := cfg.Validate()
|
||||
return &cfg, err
|
||||
}
|
||||
|
||||
// ValidateOverrides validates the alert's provider override and, if present, the group override
|
||||
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
|
||||
_, err := provider.GetConfig(group, alert)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package zapier
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
|
||||
func TestAlertProvider_Validate(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
expected error
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
|
||||
expected: nil,
|
||||
},
|
||||
{
|
||||
name: "invalid-webhook-url",
|
||||
provider: AlertProvider{DefaultConfig: Config{}},
|
||||
expected: ErrWebhookURLNotSet,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
err := scenario.provider.Validate()
|
||||
if err != scenario.expected {
|
||||
t.Errorf("expected %v, got %v", scenario.expected, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_Send(t *testing.T) {
|
||||
defer client.InjectHTTPClient(nil)
|
||||
firstDescription := "description-1"
|
||||
secondDescription := "description-2"
|
||||
scenarios := []struct {
|
||||
name string
|
||||
provider AlertProvider
|
||||
alert alert.Alert
|
||||
resolved bool
|
||||
mockRoundTripper test.MockRoundTripper
|
||||
expectedError bool
|
||||
}{
|
||||
{
|
||||
name: "triggered",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
if r.Host != "hooks.zapier.com" {
|
||||
t.Errorf("expected host hooks.zapier.com, got %s", r.Host)
|
||||
}
|
||||
if r.URL.Path != "/hooks/catch/123456/abcdef/" {
|
||||
t.Errorf("expected path /hooks/catch/123456/abcdef/, got %s", r.URL.Path)
|
||||
}
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["alert_type"] != "triggered" {
|
||||
t.Errorf("expected alert_type to be 'triggered', got %v", body["alert_type"])
|
||||
}
|
||||
if body["status"] != "critical" {
|
||||
t.Errorf("expected status to be 'critical', got %v", body["status"])
|
||||
}
|
||||
if body["endpoint"] != "endpoint-name" {
|
||||
t.Errorf("expected endpoint to be 'endpoint-name', got %v", body["endpoint"])
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "Alert") {
|
||||
t.Errorf("expected message to contain 'Alert', got %s", message)
|
||||
}
|
||||
if !strings.Contains(message, "failed 3 time(s)") {
|
||||
t.Errorf("expected message to contain failure count, got %s", message)
|
||||
}
|
||||
if body["description"] != firstDescription {
|
||||
t.Errorf("expected description to be '%s', got %v", firstDescription, body["description"])
|
||||
}
|
||||
conditionResults := body["condition_results"].([]interface{})
|
||||
if len(conditionResults) != 2 {
|
||||
t.Errorf("expected 2 condition results, got %d", len(conditionResults))
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "resolved",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
|
||||
alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: true,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
body := make(map[string]interface{})
|
||||
json.NewDecoder(r.Body).Decode(&body)
|
||||
if body["alert_type"] != "resolved" {
|
||||
t.Errorf("expected alert_type to be 'resolved', got %v", body["alert_type"])
|
||||
}
|
||||
if body["status"] != "ok" {
|
||||
t.Errorf("expected status to be 'ok', got %v", body["status"])
|
||||
}
|
||||
message := body["message"].(string)
|
||||
if !strings.Contains(message, "resolved") {
|
||||
t.Errorf("expected message to contain 'resolved', got %s", message)
|
||||
}
|
||||
if body["description"] != secondDescription {
|
||||
t.Errorf("expected description to be '%s', got %v", secondDescription, body["description"])
|
||||
}
|
||||
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: false,
|
||||
},
|
||||
{
|
||||
name: "error-response",
|
||||
provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://hooks.zapier.com/hooks/catch/123456/abcdef/"}},
|
||||
alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
|
||||
resolved: false,
|
||||
mockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
|
||||
return &http.Response{StatusCode: http.StatusUnauthorized, Body: http.NoBody}
|
||||
}),
|
||||
expectedError: true,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
client.InjectHTTPClient(&http.Client{Transport: scenario.mockRoundTripper})
|
||||
err := scenario.provider.Send(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
&scenario.alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{Condition: "[CONNECTED] == true", Success: scenario.resolved},
|
||||
{Condition: "[STATUS] == 200", Success: scenario.resolved},
|
||||
},
|
||||
},
|
||||
scenario.resolved,
|
||||
)
|
||||
if scenario.expectedError && err == nil {
|
||||
t.Error("expected error, got none")
|
||||
}
|
||||
if !scenario.expectedError && err != nil {
|
||||
t.Error("expected no error, got", err.Error())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
|
||||
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
|
||||
t.Error("expected default alert to be not nil")
|
||||
}
|
||||
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
|
||||
t.Error("expected default alert to be nil")
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -52,6 +52,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
},
|
||||
ReadBufferSize: cfg.Web.ReadBufferSize,
|
||||
Network: fiber.NetworkTCP,
|
||||
Immutable: true, // If not enabled, will cause issues due to fiber's zero allocation. See #1268 and https://docs.gofiber.io/#zero-allocation
|
||||
})
|
||||
if os.Getenv("ENVIRONMENT") == "dev" {
|
||||
app.Use(cors.New(cors.Config{
|
||||
@@ -87,7 +88,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
unprotectedAPIRouter.Post("/v1/endpoints/:key/external", CreateExternalEndpointResult(cfg))
|
||||
// SPA
|
||||
app.Get("/", SinglePageApplication(cfg.UI))
|
||||
app.Get("/endpoints/:name", SinglePageApplication(cfg.UI))
|
||||
app.Get("/endpoints/:key", SinglePageApplication(cfg.UI))
|
||||
app.Get("/suites/:key", SinglePageApplication(cfg.UI))
|
||||
// Health endpoint
|
||||
healthHandler := health.Handler().WithJSON(true)
|
||||
app.Get("/health", func(c *fiber.Ctx) error {
|
||||
@@ -127,5 +129,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
}
|
||||
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
|
||||
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
|
||||
protectedAPIRouter.Get("/v1/suites/statuses", SuiteStatuses(cfg))
|
||||
protectedAPIRouter.Get("/v1/suites/:key/statuses", SuiteStatus(cfg))
|
||||
return app
|
||||
}
|
||||
+4
-4
@@ -34,8 +34,8 @@ func TestBadge(t *testing.T) {
|
||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
@@ -284,8 +284,8 @@ func TestGetBadgeColorFromResponseTime(t *testing.T) {
|
||||
},
|
||||
}
|
||||
|
||||
store.Get().Insert(&firstTestEndpoint, &testSuccessfulResult)
|
||||
store.Get().Insert(&secondTestEndpoint, &testSuccessfulResult)
|
||||
store.Get().InsertEndpointResult(&firstTestEndpoint, &testSuccessfulResult)
|
||||
store.Get().InsertEndpointResult(&secondTestEndpoint, &testSuccessfulResult)
|
||||
|
||||
scenarios := []struct {
|
||||
Key string
|
||||
|
||||
+2
-2
@@ -28,8 +28,8 @@ func TestResponseTimeChart(t *testing.T) {
|
||||
},
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
|
||||
@@ -101,8 +101,8 @@ func TestEndpointStatus(t *testing.T) {
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
@@ -156,8 +156,8 @@ func TestEndpointStatuses(t *testing.T) {
|
||||
defer cache.Clear()
|
||||
firstResult := &testSuccessfulResult
|
||||
secondResult := &testUnsuccessfulResult
|
||||
store.Get().Insert(&testEndpoint, firstResult)
|
||||
store.Get().Insert(&testEndpoint, secondResult)
|
||||
store.Get().InsertEndpointResult(&testEndpoint, firstResult)
|
||||
store.Get().InsertEndpointResult(&testEndpoint, secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
|
||||
@@ -56,11 +56,11 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
}
|
||||
result.Duration = parsedDuration
|
||||
}
|
||||
if !result.Success && c.Query("error") != "" {
|
||||
result.Errors = append(result.Errors, c.Query("error"))
|
||||
if errorFromQuery := c.Query("error"); !result.Success && len(errorFromQuery) > 0 {
|
||||
result.AddError(errorFromQuery)
|
||||
}
|
||||
convertedEndpoint := externalEndpoint.ToEndpoint()
|
||||
if err := store.Get().Insert(convertedEndpoint, result); err != nil {
|
||||
if err := store.Get().InsertEndpointResult(convertedEndpoint, result); err != nil {
|
||||
if errors.Is(err, common.ErrEndpointNotFound) {
|
||||
return c.Status(404).SendString(err.Error())
|
||||
}
|
||||
|
||||
+2
-2
@@ -33,8 +33,8 @@ func TestRawDataEndpoint(t *testing.T) {
|
||||
cfg.Endpoints[0].UIConfig = ui.GetDefaultConfig()
|
||||
cfg.Endpoints[1].UIConfig = ui.GetDefaultConfig()
|
||||
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Connected: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Connected: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
|
||||
+2
-2
@@ -34,8 +34,8 @@ func TestSinglePageApplication(t *testing.T) {
|
||||
Title: "example-title",
|
||||
},
|
||||
}
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()})
|
||||
watchdog.UpdateEndpointStatus(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/storage/store/common/paging"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// SuiteStatuses handles requests to retrieve all suite statuses
|
||||
func SuiteStatuses(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c, 100)
|
||||
params := paging.NewSuiteStatusParams().WithPagination(page, pageSize)
|
||||
suiteStatuses, err := store.Get().GetAllSuiteStatuses(params)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
|
||||
"error": fmt.Sprintf("Failed to retrieve suite statuses: %v", err),
|
||||
})
|
||||
}
|
||||
// If no statuses exist yet, create empty ones from config
|
||||
if len(suiteStatuses) == 0 {
|
||||
for _, s := range cfg.Suites {
|
||||
if s.IsEnabled() {
|
||||
suiteStatuses = append(suiteStatuses, suite.NewStatus(s))
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(suiteStatuses)
|
||||
}
|
||||
}
|
||||
|
||||
// SuiteStatus handles requests to retrieve a single suite's status
|
||||
func SuiteStatus(cfg *config.Config) fiber.Handler {
|
||||
return func(c *fiber.Ctx) error {
|
||||
page, pageSize := extractPageAndPageSizeFromRequest(c, 100)
|
||||
key := c.Params("key")
|
||||
params := paging.NewSuiteStatusParams().WithPagination(page, pageSize)
|
||||
status, err := store.Get().GetSuiteStatusByKey(key, params)
|
||||
if err != nil || status == nil {
|
||||
// Try to find the suite in config
|
||||
for _, s := range cfg.Suites {
|
||||
if s.Key() == key {
|
||||
status = suite.NewStatus(s)
|
||||
break
|
||||
}
|
||||
}
|
||||
if status == nil {
|
||||
return c.Status(404).JSON(fiber.Map{
|
||||
"error": fmt.Sprintf("Suite with key '%s' not found", key),
|
||||
})
|
||||
}
|
||||
}
|
||||
return c.Status(fiber.StatusOK).JSON(status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,519 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/gatus/v5/storage/store"
|
||||
"github.com/TwiN/gatus/v5/watchdog"
|
||||
)
|
||||
|
||||
var (
|
||||
suiteTimestamp = time.Now()
|
||||
|
||||
testSuiteEndpoint1 = endpoint.Endpoint{
|
||||
Name: "endpoint1",
|
||||
Group: "suite-group",
|
||||
URL: "https://example.org/endpoint1",
|
||||
Method: "GET",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 500")},
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuiteEndpoint2 = endpoint.Endpoint{
|
||||
Name: "endpoint2",
|
||||
Group: "suite-group",
|
||||
URL: "https://example.org/endpoint2",
|
||||
Method: "GET",
|
||||
Interval: 30 * time.Second,
|
||||
Conditions: []endpoint.Condition{endpoint.Condition("[STATUS] == 200"), endpoint.Condition("[RESPONSE_TIME] < 300")},
|
||||
NumberOfFailuresInARow: 0,
|
||||
NumberOfSuccessesInARow: 0,
|
||||
}
|
||||
testSuite = suite.Suite{
|
||||
Name: "test-suite",
|
||||
Group: "suite-group",
|
||||
Interval: 60 * time.Second,
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
&testSuiteEndpoint1,
|
||||
&testSuiteEndpoint2,
|
||||
},
|
||||
}
|
||||
testSuccessfulSuiteResult = suite.Result{
|
||||
Name: "test-suite",
|
||||
Group: "suite-group",
|
||||
Success: true,
|
||||
Timestamp: suiteTimestamp,
|
||||
Duration: 250 * time.Millisecond,
|
||||
EndpointResults: []*endpoint.Result{
|
||||
{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Success: true,
|
||||
Timestamp: suiteTimestamp,
|
||||
Duration: 100 * time.Millisecond,
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Success: true,
|
||||
Timestamp: suiteTimestamp,
|
||||
Duration: 150 * time.Millisecond,
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 300",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
testUnsuccessfulSuiteResult = suite.Result{
|
||||
Name: "test-suite",
|
||||
Group: "suite-group",
|
||||
Success: false,
|
||||
Timestamp: suiteTimestamp,
|
||||
Duration: 850 * time.Millisecond,
|
||||
Errors: []string{"suite-error-1", "suite-error-2"},
|
||||
EndpointResults: []*endpoint.Result{
|
||||
{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 200,
|
||||
Success: true,
|
||||
Timestamp: suiteTimestamp,
|
||||
Duration: 100 * time.Millisecond,
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: true,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 500",
|
||||
Success: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Hostname: "example.org",
|
||||
IP: "127.0.0.1",
|
||||
HTTPStatus: 500,
|
||||
Errors: []string{"endpoint-error-1"},
|
||||
Success: false,
|
||||
Timestamp: suiteTimestamp,
|
||||
Duration: 750 * time.Millisecond,
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
{
|
||||
Condition: "[STATUS] == 200",
|
||||
Success: false,
|
||||
},
|
||||
{
|
||||
Condition: "[RESPONSE_TIME] < 300",
|
||||
Success: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
func TestSuiteStatus(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "frontend-suite",
|
||||
Group: "core",
|
||||
},
|
||||
{
|
||||
Name: "backend-suite",
|
||||
Group: "core",
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
}
|
||||
watchdog.UpdateSuiteStatus(cfg.Suites[0], &suite.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now(), Name: cfg.Suites[0].Name, Group: cfg.Suites[0].Group})
|
||||
watchdog.UpdateSuiteStatus(cfg.Suites[1], &suite.Result{Success: false, Duration: time.Second, Timestamp: time.Now(), Name: cfg.Suites[1].Name, Group: cfg.Suites[1].Group})
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
Gzip bool
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "suite-status",
|
||||
Path: "/api/v1/suites/core_frontend-suite/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "suite-status-gzip",
|
||||
Path: "/api/v1/suites/core_frontend-suite/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
Gzip: true,
|
||||
},
|
||||
{
|
||||
Name: "suite-status-pagination",
|
||||
Path: "/api/v1/suites/core_frontend-suite/statuses?page=1&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
},
|
||||
{
|
||||
Name: "suite-status-for-invalid-key",
|
||||
Path: "/api/v1/suites/invalid_key/statuses",
|
||||
ExpectedCode: http.StatusNotFound,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
if scenario.Gzip {
|
||||
request.Header.Set("Accept-Encoding", "gzip")
|
||||
}
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuiteStatus_SuiteNotInStoreButInConfig(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
tests := []struct {
|
||||
name string
|
||||
suiteKey string
|
||||
cfg *config.Config
|
||||
expectedCode int
|
||||
expectJSON bool
|
||||
expectError string
|
||||
}{
|
||||
{
|
||||
name: "suite-not-in-store-but-exists-in-config-enabled",
|
||||
suiteKey: "test-group_test-suite",
|
||||
cfg: &config.Config{
|
||||
Metrics: true,
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "test-suite",
|
||||
Group: "test-group",
|
||||
Enabled: boolPtr(true),
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint-1",
|
||||
Group: "test-group",
|
||||
URL: "https://example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
},
|
||||
expectedCode: http.StatusOK,
|
||||
expectJSON: true,
|
||||
},
|
||||
{
|
||||
name: "suite-not-in-store-but-exists-in-config-disabled",
|
||||
suiteKey: "test-group_disabled-suite",
|
||||
cfg: &config.Config{
|
||||
Metrics: true,
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "disabled-suite",
|
||||
Group: "test-group",
|
||||
Enabled: boolPtr(false),
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
},
|
||||
expectedCode: http.StatusOK,
|
||||
expectJSON: true,
|
||||
},
|
||||
{
|
||||
name: "suite-not-in-store-and-not-in-config",
|
||||
suiteKey: "nonexistent_suite",
|
||||
cfg: &config.Config{
|
||||
Metrics: true,
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "different-suite",
|
||||
Group: "different-group",
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
},
|
||||
expectedCode: http.StatusNotFound,
|
||||
expectError: "Suite with key 'nonexistent_suite' not found",
|
||||
},
|
||||
{
|
||||
name: "suite-with-empty-group-in-config",
|
||||
suiteKey: "_empty-group-suite",
|
||||
cfg: &config.Config{
|
||||
Metrics: true,
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "empty-group-suite",
|
||||
Group: "",
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
},
|
||||
expectedCode: http.StatusOK,
|
||||
expectJSON: true,
|
||||
},
|
||||
{
|
||||
name: "suite-nil-enabled-defaults-to-true",
|
||||
suiteKey: "default_enabled-suite",
|
||||
cfg: &config.Config{
|
||||
Metrics: true,
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "enabled-suite",
|
||||
Group: "default",
|
||||
Enabled: nil,
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
},
|
||||
expectedCode: http.StatusOK,
|
||||
expectJSON: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
api := New(tt.cfg)
|
||||
router := api.Router()
|
||||
request := httptest.NewRequest("GET", "/api/v1/suites/"+tt.suiteKey+"/statuses", http.NoBody)
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Router test failed: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != tt.expectedCode {
|
||||
t.Errorf("Expected status code %d, got %d", tt.expectedCode, response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
bodyStr := string(body)
|
||||
if tt.expectJSON {
|
||||
if response.Header.Get("Content-Type") != "application/json" {
|
||||
t.Errorf("Expected JSON content type, got %s", response.Header.Get("Content-Type"))
|
||||
}
|
||||
if len(bodyStr) == 0 || bodyStr[0] != '{' {
|
||||
t.Errorf("Expected JSON response, got: %s", bodyStr)
|
||||
}
|
||||
}
|
||||
if tt.expectError != "" {
|
||||
if !contains(bodyStr, tt.expectError) {
|
||||
t.Errorf("Expected error message '%s' in response, got: %s", tt.expectError, bodyStr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuiteStatuses(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
firstResult := &testSuccessfulSuiteResult
|
||||
secondResult := &testUnsuccessfulSuiteResult
|
||||
store.Get().InsertSuiteResult(&testSuite, firstResult)
|
||||
store.Get().InsertSuiteResult(&testSuite, secondResult)
|
||||
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests
|
||||
firstResult.Timestamp = time.Time{}
|
||||
secondResult.Timestamp = time.Time{}
|
||||
for i := range firstResult.EndpointResults {
|
||||
firstResult.EndpointResults[i].Timestamp = time.Time{}
|
||||
}
|
||||
for i := range secondResult.EndpointResults {
|
||||
secondResult.EndpointResults[i].Timestamp = time.Time{}
|
||||
}
|
||||
api := New(&config.Config{
|
||||
Metrics: true,
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
})
|
||||
router := api.Router()
|
||||
type Scenario struct {
|
||||
Name string
|
||||
Path string
|
||||
ExpectedCode int
|
||||
ExpectedBody string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
Name: "no-pagination",
|
||||
Path: "/api/v1/suites/statuses",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]},{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-first-result",
|
||||
Path: "/api/v1/suites/statuses?page=1&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-second-result",
|
||||
Path: "/api/v1/suites/statuses?page=2&pageSize=1",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]}]}]`,
|
||||
},
|
||||
{
|
||||
Name: "pagination-no-results",
|
||||
Path: "/api/v1/suites/statuses?page=5&pageSize=20",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[]}]`,
|
||||
},
|
||||
{
|
||||
Name: "invalid-pagination-should-fall-back-to-default",
|
||||
Path: "/api/v1/suites/statuses?page=INVALID&pageSize=INVALID",
|
||||
ExpectedCode: http.StatusOK,
|
||||
ExpectedBody: `[{"name":"test-suite","group":"suite-group","key":"suite-group_test-suite","results":[{"name":"test-suite","group":"suite-group","success":true,"timestamp":"0001-01-01T00:00:00Z","duration":250000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":200,"hostname":"example.org","duration":150000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 300","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"}]},{"name":"test-suite","group":"suite-group","success":false,"timestamp":"0001-01-01T00:00:00Z","duration":850000000,"endpointResults":[{"status":200,"hostname":"example.org","duration":100000000,"conditionResults":[{"condition":"[STATUS] == 200","success":true},{"condition":"[RESPONSE_TIME] \u003c 500","success":true}],"success":true,"timestamp":"0001-01-01T00:00:00Z"},{"status":500,"hostname":"example.org","duration":750000000,"errors":["endpoint-error-1"],"conditionResults":[{"condition":"[STATUS] == 200","success":false},{"condition":"[RESPONSE_TIME] \u003c 300","success":false}],"success":false,"timestamp":"0001-01-01T00:00:00Z"}],"errors":["suite-error-1","suite-error-2"]}]}]`,
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
request := httptest.NewRequest("GET", scenario.Path, http.NoBody)
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != scenario.ExpectedCode {
|
||||
t.Errorf("%s %s should have returned %d, but returned %d instead", request.Method, request.URL, scenario.ExpectedCode, response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if string(body) != scenario.ExpectedBody {
|
||||
t.Errorf("expected:\n %s\n\ngot:\n %s", scenario.ExpectedBody, string(body))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuiteStatuses_NoSuitesInStoreButExistInConfig(t *testing.T) {
|
||||
defer store.Get().Clear()
|
||||
defer cache.Clear()
|
||||
cfg := &config.Config{
|
||||
Metrics: true,
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "config-only-suite-1",
|
||||
Group: "test-group",
|
||||
Enabled: boolPtr(true),
|
||||
},
|
||||
{
|
||||
Name: "config-only-suite-2",
|
||||
Group: "test-group",
|
||||
Enabled: boolPtr(true),
|
||||
},
|
||||
{
|
||||
Name: "disabled-suite",
|
||||
Group: "test-group",
|
||||
Enabled: boolPtr(false),
|
||||
},
|
||||
},
|
||||
Storage: &storage.Config{
|
||||
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
|
||||
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
|
||||
},
|
||||
}
|
||||
api := New(cfg)
|
||||
router := api.Router()
|
||||
request := httptest.NewRequest("GET", "/api/v1/suites/statuses", http.NoBody)
|
||||
response, err := router.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("Router test failed: %v", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Errorf("Expected status code %d, got %d", http.StatusOK, response.StatusCode)
|
||||
}
|
||||
body, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read response body: %v", err)
|
||||
}
|
||||
bodyStr := string(body)
|
||||
if !contains(bodyStr, "config-only-suite-1") {
|
||||
t.Error("Expected config-only-suite-1 in response")
|
||||
}
|
||||
if !contains(bodyStr, "config-only-suite-2") {
|
||||
t.Error("Expected config-only-suite-2 in response")
|
||||
}
|
||||
if contains(bodyStr, "disabled-suite") {
|
||||
t.Error("Should not include disabled-suite in response")
|
||||
}
|
||||
}
|
||||
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > len(substr) && (s[:len(substr)] == substr || s[len(s)-len(substr):] == substr ||
|
||||
func() bool {
|
||||
for i := 1; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}())))
|
||||
}
|
||||
+2
-2
@@ -10,8 +10,8 @@ const (
|
||||
// DefaultPage is the default page to use if none is specified or an invalid value is provided
|
||||
DefaultPage = 1
|
||||
|
||||
// DefaultPageSize is the default page siZE to use if none is specified or an invalid value is provided
|
||||
DefaultPageSize = 20
|
||||
// DefaultPageSize is the default page size to use if none is specified or an invalid value is provided
|
||||
DefaultPageSize = 50
|
||||
)
|
||||
|
||||
func extractPageAndPageSizeFromRequest(c *fiber.Ctx, maximumNumberOfResults int) (page, pageSize int) {
|
||||
|
||||
+98
-4
@@ -1,8 +1,10 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -20,6 +22,8 @@ import (
|
||||
"github.com/ishidawataru/sctp"
|
||||
"github.com/miekg/dns"
|
||||
ping "github.com/prometheus-community/pro-bing"
|
||||
"github.com/registrobr/rdap"
|
||||
"github.com/registrobr/rdap/protocol"
|
||||
"golang.org/x/crypto/ssh"
|
||||
"golang.org/x/net/websocket"
|
||||
)
|
||||
@@ -34,6 +38,7 @@ var (
|
||||
|
||||
whoisClient = whois.NewClient().WithReferralCache(true)
|
||||
whoisExpirationDateCache = gocache.NewCache().WithMaxSize(10000).WithDefaultTTL(24 * time.Hour)
|
||||
rdapClient = rdap.NewClient(nil)
|
||||
)
|
||||
|
||||
// GetHTTPClient returns the shared HTTP client, or the client from the configuration passed
|
||||
@@ -61,7 +66,12 @@ func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err e
|
||||
return domainExpiration, nil
|
||||
}
|
||||
}
|
||||
if whoisResponse, err := whoisClient.QueryAndParse(hostname); err != nil {
|
||||
whoisResponse, err := rdapQuery(hostname)
|
||||
if err != nil {
|
||||
// fallback to WHOIS protocol
|
||||
whoisResponse, err = whoisClient.QueryAndParse(hostname)
|
||||
}
|
||||
if err != nil {
|
||||
if !retrievedCachedValue { // Add an error unless we already retrieved a cached value
|
||||
return 0, fmt.Errorf("error querying and parsing hostname using whois client: %w", err)
|
||||
}
|
||||
@@ -141,10 +151,39 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
|
||||
if len(hostAndPort) != 2 {
|
||||
return false, nil, errors.New("invalid address for starttls, format must be host:port")
|
||||
}
|
||||
connection, err := net.DialTimeout("tcp", address, config.Timeout)
|
||||
if err != nil {
|
||||
return
|
||||
|
||||
var connection net.Conn
|
||||
var dnsResolver *DNSResolverConfig
|
||||
|
||||
if config.HasCustomDNSResolver() {
|
||||
dnsResolver, err = config.parseDNSResolver()
|
||||
|
||||
if err != nil {
|
||||
// We're ignoring the error, because it should have been validated on startup ValidateAndSetDefaults.
|
||||
// It shouldn't happen, but if it does, we'll log it... Better safe than sorry ;)
|
||||
logr.Errorf("[client.getHTTPClient] THIS SHOULD NOT HAPPEN. Silently ignoring invalid DNS resolver due to error: %s", err.Error())
|
||||
} else {
|
||||
dialer := &net.Dialer{
|
||||
Resolver: &net.Resolver{
|
||||
PreferGo: true,
|
||||
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
|
||||
d := net.Dialer{}
|
||||
return d.DialContext(ctx, dnsResolver.Protocol, dnsResolver.Host+":"+dnsResolver.Port)
|
||||
},
|
||||
},
|
||||
}
|
||||
connection, err = dialer.DialContext(context.Background(), "tcp", address)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
connection, err = net.DialTimeout("tcp", address, config.Timeout)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
smtpClient, err := smtp.NewClient(connection, hostAndPort[0])
|
||||
if err != nil {
|
||||
return
|
||||
@@ -372,6 +411,17 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
|
||||
url = fmt.Sprintf("%s:%d", url, dnsPort)
|
||||
}
|
||||
queryTypeAsUint16 := dns.StringToType[queryType]
|
||||
// Special handling: if this is a PTR query and queryName looks like a plain IP,
|
||||
// convert it to the proper reverse lookup domain automatically.
|
||||
if queryTypeAsUint16 == dns.TypePTR &&
|
||||
!strings.HasSuffix(queryName, ".in-addr.arpa.") &&
|
||||
!strings.HasSuffix(queryName, ".ip6.arpa.") {
|
||||
if rev, convErr := reverseNameForIP(queryName); convErr == nil {
|
||||
queryName = rev
|
||||
} else {
|
||||
return false, "", nil, convErr
|
||||
}
|
||||
}
|
||||
c := new(dns.Client)
|
||||
m := new(dns.Msg)
|
||||
m.SetQuestion(queryName, queryTypeAsUint16)
|
||||
@@ -423,3 +473,47 @@ func QueryDNS(queryType, queryName, url string) (connected bool, dnsRcode string
|
||||
func InjectHTTPClient(httpClient *http.Client) {
|
||||
injectedHTTPClient = httpClient
|
||||
}
|
||||
|
||||
// rdapQuery returns domain expiration via RDAP protocol
|
||||
func rdapQuery(hostname string) (*whois.Response, error) {
|
||||
data, _, err := rdapClient.Query(hostname, nil, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
domain, ok := data.(*protocol.Domain)
|
||||
if !ok {
|
||||
return nil, errors.New("invalid domain type")
|
||||
}
|
||||
response := whois.Response{}
|
||||
for _, e := range domain.Events {
|
||||
if e.Action == "expiration" {
|
||||
response.ExpirationDate = e.Date.Time
|
||||
break
|
||||
}
|
||||
}
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// helper to reverse IP and add in-addr.arpa. IPv4 and IPv6
|
||||
func reverseNameForIP(ipStr string) (string, error) {
|
||||
ip := net.ParseIP(ipStr)
|
||||
if ip == nil {
|
||||
return "", fmt.Errorf("invalid IP: %s", ipStr)
|
||||
}
|
||||
|
||||
if ipv4 := ip.To4(); ipv4 != nil {
|
||||
parts := strings.Split(ipv4.String(), ".")
|
||||
for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 {
|
||||
parts[i], parts[j] = parts[j], parts[i]
|
||||
}
|
||||
return strings.Join(parts, ".") + ".in-addr.arpa.", nil
|
||||
}
|
||||
|
||||
ip = ip.To16()
|
||||
hexStr := hex.EncodeToString(ip)
|
||||
nibbles := strings.Split(hexStr, "")
|
||||
for i, j := 0, len(nibbles)-1; i < j; i, j = i+1, j-1 {
|
||||
nibbles[i], nibbles[j] = nibbles[j], nibbles[i]
|
||||
}
|
||||
return strings.Join(nibbles, ".") + ".ip6.arpa.", nil
|
||||
}
|
||||
+35
-1
@@ -39,6 +39,20 @@ func TestGetHTTPClient(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRdapQuery(t *testing.T) {
|
||||
if _, err := rdapQuery("1.1.1.1"); err == nil {
|
||||
t.Error("expected an error due to the invalid domain type")
|
||||
}
|
||||
if _, err := rdapQuery("eurid.eu"); err == nil {
|
||||
t.Error("expected an error as there is no RDAP support currently in .eu")
|
||||
}
|
||||
if response, err := rdapQuery("example.com"); err != nil {
|
||||
t.Fatal("expected no error, got", err.Error())
|
||||
} else if response.ExpirationDate.Unix() <= 0 {
|
||||
t.Error("expected to have a valid expiry date, got", response.ExpirationDate.Unix())
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetDomainExpiration(t *testing.T) {
|
||||
t.Parallel()
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
@@ -119,6 +133,7 @@ func TestCanPerformStartTLS(t *testing.T) {
|
||||
type args struct {
|
||||
address string
|
||||
insecure bool
|
||||
dnsresolver string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -150,11 +165,20 @@ func TestCanPerformStartTLS(t *testing.T) {
|
||||
wantConnected: true,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "dns resolver",
|
||||
args: args{
|
||||
address: "smtp.gmail.com:587",
|
||||
dnsresolver: "tcp://1.1.1.1:53",
|
||||
},
|
||||
wantConnected: true,
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
|
||||
connected, _, err := CanPerformStartTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second, DNSResolver: tt.args.dnsresolver})
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("CanPerformStartTLS() err=%v, wantErr=%v", err, tt.wantErr)
|
||||
return
|
||||
@@ -427,6 +451,16 @@ func TestQueryDNS(t *testing.T) {
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "dns.google.",
|
||||
},
|
||||
{
|
||||
name: "test Config with type PTR and forward IP / no in-addr",
|
||||
inputDNS: dns.Config{
|
||||
QueryType: "PTR",
|
||||
QueryName: "1.0.0.1",
|
||||
},
|
||||
inputURL: "1.1.1.1",
|
||||
expectedDNSCode: "NOERROR",
|
||||
expectedBody: "one.one.one.one.",
|
||||
},
|
||||
{
|
||||
name: "test Config with fake type and retrieve error",
|
||||
inputDNS: dns.Config{
|
||||
|
||||
+17
-2
@@ -11,6 +11,7 @@ import (
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
|
||||
"github.com/TwiN/logr"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
@@ -69,13 +70,19 @@ type Config struct {
|
||||
// IAPConfig is the Google Cloud Identity-Aware-Proxy configuration used for the client. (e.g. audience)
|
||||
IAPConfig *IAPConfig `yaml:"identity-aware-proxy,omitempty"`
|
||||
|
||||
httpClient *http.Client
|
||||
|
||||
// Network (ip, ip4 or ip6) for the ICMP client
|
||||
Network string `yaml:"network"`
|
||||
|
||||
// TLS configuration (optional)
|
||||
TLS *TLSConfig `yaml:"tls,omitempty"`
|
||||
|
||||
// Tunnel is the name of the SSH tunnel to use for the client
|
||||
Tunnel string `yaml:"tunnel,omitempty"`
|
||||
|
||||
// ResolvedTunnel is the resolved SSH tunnel for this specific Config
|
||||
ResolvedTunnel *sshtunnel.SSHTunnel `yaml:"-"`
|
||||
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// DNSResolverConfig is the parsed configuration from the DNSResolver config string.
|
||||
@@ -265,6 +272,14 @@ func (c *Config) getHTTPClient() *http.Client {
|
||||
} else if c.HasIAPConfig() {
|
||||
c.httpClient = configureIAP(c.httpClient, *c.IAPConfig)
|
||||
}
|
||||
if c.ResolvedTunnel != nil {
|
||||
// Use SSH tunnel dialer
|
||||
if transport, ok := c.httpClient.Transport.(*http.Transport); ok {
|
||||
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
||||
return c.ResolvedTunnel.Dial(network, addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return c.httpClient
|
||||
}
|
||||
|
||||
+220
-41
@@ -14,11 +14,15 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting"
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/announcement"
|
||||
"github.com/TwiN/gatus/v5/config/connectivity"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/key"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/config/remote"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/TwiN/gatus/v5/config/tunneling"
|
||||
"github.com/TwiN/gatus/v5/config/ui"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
@@ -35,11 +39,14 @@ const (
|
||||
// DefaultFallbackConfigurationFilePath is the default fallback path that will be used to search for the
|
||||
// configuration file if DefaultConfigurationFilePath didn't work
|
||||
DefaultFallbackConfigurationFilePath = "config/config.yml"
|
||||
|
||||
// DefaultConcurrency is the default number of endpoints/suites that can be monitored concurrently
|
||||
DefaultConcurrency = 3
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrNoEndpointInConfig is an error returned when a configuration file or directory has no endpoints configured
|
||||
ErrNoEndpointInConfig = errors.New("configuration should contain at least 1 endpoint")
|
||||
// ErrNoEndpointOrSuiteInConfig is an error returned when a configuration file or directory has no endpoints configured
|
||||
ErrNoEndpointOrSuiteInConfig = errors.New("configuration should contain at least one endpoint or suite")
|
||||
|
||||
// ErrConfigFileNotFound is an error returned when a configuration file could not be found
|
||||
ErrConfigFileNotFound = errors.New("configuration file not found")
|
||||
@@ -67,8 +74,14 @@ type Config struct {
|
||||
// DisableMonitoringLock Whether to disable the monitoring lock
|
||||
// The monitoring lock is what prevents multiple endpoints from being processed at the same time.
|
||||
// Disabling this may lead to inaccurate response times
|
||||
//
|
||||
// Deprecated: Use Concurrency instead TODO: REMOVE THIS IN v6.0.0
|
||||
DisableMonitoringLock bool `yaml:"disable-monitoring-lock,omitempty"`
|
||||
|
||||
// Concurrency is the maximum number of endpoints/suites that can be monitored concurrently
|
||||
// Defaults to DefaultConcurrency. Set to 0 for unlimited concurrency.
|
||||
Concurrency int `yaml:"concurrency,omitempty"`
|
||||
|
||||
// Security is the configuration for securing access to Gatus
|
||||
Security *security.Config `yaml:"security,omitempty"`
|
||||
|
||||
@@ -81,6 +94,9 @@ type Config struct {
|
||||
// ExternalEndpoints is the list of all external endpoints
|
||||
ExternalEndpoints []*endpoint.ExternalEndpoint `yaml:"external-endpoints,omitempty"`
|
||||
|
||||
// Suites is the list of suites to monitor
|
||||
Suites []*suite.Suite `yaml:"suites,omitempty"`
|
||||
|
||||
// Storage is the configuration for how the data is stored
|
||||
Storage *storage.Config `yaml:"storage,omitempty"`
|
||||
|
||||
@@ -100,6 +116,9 @@ type Config struct {
|
||||
// Connectivity is the configuration for connectivity
|
||||
Connectivity *connectivity.Config `yaml:"connectivity,omitempty"`
|
||||
|
||||
// Tunneling is the configuration for SSH tunneling
|
||||
Tunneling *tunneling.Config `yaml:"tunneling,omitempty"`
|
||||
|
||||
// Announcements is the list of system-wide announcements
|
||||
Announcements []*announcement.Announcement `yaml:"announcements,omitempty"`
|
||||
|
||||
@@ -272,8 +291,8 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
return
|
||||
}
|
||||
// Check if the configuration file at least has endpoints configured
|
||||
if config == nil || config.Endpoints == nil || len(config.Endpoints) == 0 {
|
||||
err = ErrNoEndpointInConfig
|
||||
if config == nil || (len(config.Endpoints) == 0 && len(config.Suites) == 0) {
|
||||
err = ErrNoEndpointOrSuiteInConfig
|
||||
} else {
|
||||
// XXX: Remove this in v6.0.0
|
||||
if config.Debug {
|
||||
@@ -281,48 +300,111 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
|
||||
logr.Warn("WARNING: Please use the GATUS_LOG_LEVEL environment variable instead")
|
||||
}
|
||||
// XXX: End of v6.0.0 removals
|
||||
validateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
|
||||
if err := validateSecurityConfig(config); err != nil {
|
||||
ValidateAlertingConfig(config.Alerting, config.Endpoints, config.ExternalEndpoints)
|
||||
if err := ValidateSecurityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateEndpointsConfig(config); err != nil {
|
||||
if err := ValidateEndpointsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateWebConfig(config); err != nil {
|
||||
if err := ValidateWebConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateUIConfig(config); err != nil {
|
||||
if err := ValidateUIConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateMaintenanceConfig(config); err != nil {
|
||||
if err := ValidateMaintenanceConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateStorageConfig(config); err != nil {
|
||||
if err := ValidateStorageConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateRemoteConfig(config); err != nil {
|
||||
if err := ValidateRemoteConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateConnectivityConfig(config); err != nil {
|
||||
if err := ValidateConnectivityConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateAnnouncementsConfig(config); err != nil {
|
||||
if err := ValidateTunnelingConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateAnnouncementsConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateSuitesConfig(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateUniqueKeys(config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ValidateAndSetConcurrencyDefaults(config)
|
||||
// Cross-config changes
|
||||
config.UI.MaximumNumberOfResults = config.Storage.MaximumNumberOfResults
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func validateConnectivityConfig(config *Config) error {
|
||||
func ValidateConnectivityConfig(config *Config) error {
|
||||
if config.Connectivity != nil {
|
||||
return config.Connectivity.ValidateAndSetDefaults()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAnnouncementsConfig(config *Config) error {
|
||||
// ValidateTunnelingConfig validates the tunneling configuration and resolves tunnel references
|
||||
// NOTE: This must be called after ValidateEndpointsConfig and ValidateSuitesConfig
|
||||
// because it resolves tunnel references in endpoint and suite client configurations
|
||||
func ValidateTunnelingConfig(config *Config) error {
|
||||
if config.Tunneling != nil {
|
||||
if err := config.Tunneling.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve tunnel references in all endpoints
|
||||
for _, ep := range config.Endpoints {
|
||||
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
|
||||
return fmt.Errorf("endpoint '%s': %w", ep.Key(), err)
|
||||
}
|
||||
}
|
||||
// Resolve tunnel references in suite endpoints
|
||||
for _, s := range config.Suites {
|
||||
for _, ep := range s.Endpoints {
|
||||
if err := resolveTunnelForClientConfig(config, ep.ClientConfig); err != nil {
|
||||
return fmt.Errorf("suite '%s' endpoint '%s': %w", s.Key(), ep.Key(), err)
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: Add tunnel support for alert providers when needed
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveTunnelForClientConfig resolves tunnel references in a client configuration
|
||||
func resolveTunnelForClientConfig(config *Config, clientConfig *client.Config) error {
|
||||
if clientConfig == nil || clientConfig.Tunnel == "" {
|
||||
return nil
|
||||
}
|
||||
// Validate tunnel name
|
||||
tunnelName := strings.TrimSpace(clientConfig.Tunnel)
|
||||
if tunnelName == "" {
|
||||
return fmt.Errorf("tunnel name cannot be empty")
|
||||
}
|
||||
if config.Tunneling == nil {
|
||||
return fmt.Errorf("tunnel '%s' referenced but no tunneling configuration defined", tunnelName)
|
||||
}
|
||||
_, exists := config.Tunneling.Tunnels[tunnelName]
|
||||
if !exists {
|
||||
return fmt.Errorf("tunnel '%s' not found in tunneling configuration", tunnelName)
|
||||
}
|
||||
// Get or create the SSH tunnel instance and store it directly in client config
|
||||
tunnel, err := config.Tunneling.GetTunnel(tunnelName)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get tunnel '%s': %w", tunnelName, err)
|
||||
}
|
||||
clientConfig.ResolvedTunnel = tunnel
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateAnnouncementsConfig(config *Config) error {
|
||||
if config.Announcements != nil {
|
||||
if err := announcement.ValidateAndSetDefaults(config.Announcements); err != nil {
|
||||
return err
|
||||
@@ -333,7 +415,7 @@ func validateAnnouncementsConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRemoteConfig(config *Config) error {
|
||||
func ValidateRemoteConfig(config *Config) error {
|
||||
if config.Remote != nil {
|
||||
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
@@ -342,7 +424,7 @@ func validateRemoteConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateStorageConfig(config *Config) error {
|
||||
func ValidateStorageConfig(config *Config) error {
|
||||
if config.Storage == nil {
|
||||
config.Storage = &storage.Config{
|
||||
Type: storage.TypeMemory,
|
||||
@@ -357,7 +439,7 @@ func validateStorageConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateMaintenanceConfig(config *Config) error {
|
||||
func ValidateMaintenanceConfig(config *Config) error {
|
||||
if config.Maintenance == nil {
|
||||
config.Maintenance = maintenance.GetDefaultConfig()
|
||||
} else {
|
||||
@@ -368,7 +450,7 @@ func validateMaintenanceConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateUIConfig(config *Config) error {
|
||||
func ValidateUIConfig(config *Config) error {
|
||||
if config.UI == nil {
|
||||
config.UI = ui.GetDefaultConfig()
|
||||
} else {
|
||||
@@ -379,7 +461,7 @@ func validateUIConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateWebConfig(config *Config) error {
|
||||
func ValidateWebConfig(config *Config) error {
|
||||
if config.Web == nil {
|
||||
config.Web = web.GetDefaultConfig()
|
||||
} else {
|
||||
@@ -388,11 +470,11 @@ func validateWebConfig(config *Config) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEndpointsConfig(config *Config) error {
|
||||
func ValidateEndpointsConfig(config *Config) error {
|
||||
duplicateValidationMap := make(map[string]bool)
|
||||
// Validate endpoints
|
||||
for _, ep := range config.Endpoints {
|
||||
logr.Debugf("[config.validateEndpointsConfig] Validating endpoint with key %s", ep.Key())
|
||||
logr.Debugf("[config.ValidateEndpointsConfig] Validating endpoint with key %s", ep.Key())
|
||||
if endpointKey := ep.Key(); duplicateValidationMap[endpointKey] {
|
||||
return fmt.Errorf("invalid endpoint %s: name and group combination must be unique", ep.Key())
|
||||
} else {
|
||||
@@ -402,10 +484,10 @@ func validateEndpointsConfig(config *Config) error {
|
||||
return fmt.Errorf("invalid endpoint %s: %w", ep.Key(), err)
|
||||
}
|
||||
}
|
||||
logr.Infof("[config.validateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
logr.Infof("[config.ValidateEndpointsConfig] Validated %d endpoints", len(config.Endpoints))
|
||||
// Validate external endpoints
|
||||
for _, ee := range config.ExternalEndpoints {
|
||||
logr.Debugf("[config.validateEndpointsConfig] Validating external endpoint '%s'", ee.Name)
|
||||
logr.Debugf("[config.ValidateEndpointsConfig] Validating external endpoint '%s'", ee.Key())
|
||||
if endpointKey := ee.Key(); duplicateValidationMap[endpointKey] {
|
||||
return fmt.Errorf("invalid external endpoint %s: name and group combination must be unique", ee.Key())
|
||||
} else {
|
||||
@@ -415,35 +497,105 @@ func validateEndpointsConfig(config *Config) error {
|
||||
return fmt.Errorf("invalid external endpoint %s: %w", ee.Key(), err)
|
||||
}
|
||||
}
|
||||
logr.Infof("[config.validateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
|
||||
logr.Infof("[config.ValidateEndpointsConfig] Validated %d external endpoints", len(config.ExternalEndpoints))
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateSecurityConfig(config *Config) error {
|
||||
func ValidateSuitesConfig(config *Config) error {
|
||||
if config.Suites == nil || len(config.Suites) == 0 {
|
||||
logr.Info("[config.ValidateSuitesConfig] No suites configured")
|
||||
return nil
|
||||
}
|
||||
suiteNames := make(map[string]bool)
|
||||
for _, suite := range config.Suites {
|
||||
// Check for duplicate suite names
|
||||
if suiteNames[suite.Name] {
|
||||
return fmt.Errorf("duplicate suite name: %s", suite.Key())
|
||||
}
|
||||
suiteNames[suite.Name] = true
|
||||
// Validate the suite configuration
|
||||
if err := suite.ValidateAndSetDefaults(); err != nil {
|
||||
return fmt.Errorf("invalid suite '%s': %w", suite.Key(), err)
|
||||
}
|
||||
// Check that endpoints referenced in Store mappings use valid placeholders
|
||||
for _, suiteEndpoint := range suite.Endpoints {
|
||||
if suiteEndpoint.Store != nil {
|
||||
for contextKey, placeholder := range suiteEndpoint.Store {
|
||||
// Basic validation that the context key is a valid identifier
|
||||
if len(contextKey) == 0 {
|
||||
return fmt.Errorf("suite '%s' endpoint '%s' has empty context key in store mapping", suite.Key(), suiteEndpoint.Key())
|
||||
}
|
||||
if len(placeholder) == 0 {
|
||||
return fmt.Errorf("suite '%s' endpoint '%s' has empty placeholder in store mapping for key '%s'", suite.Key(), suiteEndpoint.Key(), contextKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
logr.Infof("[config.ValidateSuitesConfig] Validated %d suite(s)", len(config.Suites))
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateUniqueKeys(config *Config) error {
|
||||
keyMap := make(map[string]string) // key -> description for error messages
|
||||
// Check all endpoints
|
||||
for _, ep := range config.Endpoints {
|
||||
epKey := ep.Key()
|
||||
if existing, exists := keyMap[epKey]; exists {
|
||||
return fmt.Errorf("duplicate key '%s': endpoint '%s' conflicts with %s", epKey, ep.Key(), existing)
|
||||
}
|
||||
keyMap[epKey] = fmt.Sprintf("endpoint '%s'", ep.Key())
|
||||
}
|
||||
// Check all external endpoints
|
||||
for _, ee := range config.ExternalEndpoints {
|
||||
eeKey := ee.Key()
|
||||
if existing, exists := keyMap[eeKey]; exists {
|
||||
return fmt.Errorf("duplicate key '%s': external endpoint '%s' conflicts with %s", eeKey, ee.Key(), existing)
|
||||
}
|
||||
keyMap[eeKey] = fmt.Sprintf("external endpoint '%s'", ee.Key())
|
||||
}
|
||||
// Check all suites
|
||||
for _, suite := range config.Suites {
|
||||
suiteKey := suite.Key()
|
||||
if existing, exists := keyMap[suiteKey]; exists {
|
||||
return fmt.Errorf("duplicate key '%s': suite '%s' conflicts with %s", suiteKey, suite.Key(), existing)
|
||||
}
|
||||
keyMap[suiteKey] = fmt.Sprintf("suite '%s'", suite.Key())
|
||||
// Check endpoints within suites (they generate keys using suite group + endpoint name)
|
||||
for _, ep := range suite.Endpoints {
|
||||
epKey := key.ConvertGroupAndNameToKey(suite.Group, ep.Name)
|
||||
if existing, exists := keyMap[epKey]; exists {
|
||||
return fmt.Errorf("duplicate key '%s': endpoint '%s' in suite '%s' conflicts with %s", epKey, epKey, suite.Key(), existing)
|
||||
}
|
||||
keyMap[epKey] = fmt.Sprintf("endpoint '%s' in suite '%s'", epKey, suite.Key())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateSecurityConfig(config *Config) error {
|
||||
if config.Security != nil {
|
||||
if config.Security.IsValid() {
|
||||
logr.Debug("[config.validateSecurityConfig] Basic security configuration has been validated")
|
||||
} else {
|
||||
// If there was an attempt to configure security, then it must mean that some confidential or private
|
||||
// data are exposed. As a result, we'll force a panic because it's better to be safe than sorry.
|
||||
if !config.Security.ValidateAndSetDefaults() {
|
||||
logr.Debug("[config.ValidateSecurityConfig] Basic security configuration has been validated")
|
||||
return ErrInvalidSecurityConfig
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAlertingConfig validates the alerting configuration
|
||||
// ValidateAlertingConfig validates the alerting configuration
|
||||
// Note that the alerting configuration has to be validated before the endpoint configuration, because the default alert
|
||||
// returned by provider.AlertProvider.GetDefaultAlert() must be parsed before endpoint.Endpoint.ValidateAndSetDefaults()
|
||||
// sets the default alert values when none are set.
|
||||
func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
|
||||
func ValidateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoint.Endpoint, externalEndpoints []*endpoint.ExternalEndpoint) {
|
||||
if alertingConfig == nil {
|
||||
logr.Info("[config.validateAlertingConfig] Alerting is not configured")
|
||||
logr.Info("[config.ValidateAlertingConfig] Alerting is not configured")
|
||||
return
|
||||
}
|
||||
alertTypes := []alert.Type{
|
||||
alert.TypeAWSSES,
|
||||
alert.TypeCustom,
|
||||
alert.TypeDatadog,
|
||||
alert.TypeDiscord,
|
||||
alert.TypeEmail,
|
||||
alert.TypeGitHub,
|
||||
@@ -452,21 +604,34 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
alert.TypeGoogleChat,
|
||||
alert.TypeGotify,
|
||||
alert.TypeHomeAssistant,
|
||||
alert.TypeIFTTT,
|
||||
alert.TypeIlert,
|
||||
alert.TypeIncidentIO,
|
||||
alert.TypeJetBrainsSpace,
|
||||
alert.TypeLine,
|
||||
alert.TypeMatrix,
|
||||
alert.TypeMattermost,
|
||||
alert.TypeMessagebird,
|
||||
alert.TypeNewRelic,
|
||||
alert.TypeNtfy,
|
||||
alert.TypeOpsgenie,
|
||||
alert.TypePagerDuty,
|
||||
alert.TypePlivo,
|
||||
alert.TypePushover,
|
||||
alert.TypeRocketChat,
|
||||
alert.TypeSendGrid,
|
||||
alert.TypeSignal,
|
||||
alert.TypeSIGNL4,
|
||||
alert.TypeSlack,
|
||||
alert.TypeSplunk,
|
||||
alert.TypeSquadcast,
|
||||
alert.TypeTeams,
|
||||
alert.TypeTeamsWorkflows,
|
||||
alert.TypeTelegram,
|
||||
alert.TypeTwilio,
|
||||
alert.TypeVonage,
|
||||
alert.TypeWebex,
|
||||
alert.TypeZapier,
|
||||
alert.TypeZulip,
|
||||
}
|
||||
var validProviders, invalidProviders []alert.Type
|
||||
@@ -479,12 +644,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
for _, ep := range endpoints {
|
||||
for alertIndex, endpointAlert := range ep.Alerts {
|
||||
if alertType == endpointAlert.Type {
|
||||
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
|
||||
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ep.Key())
|
||||
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||
// Validate the endpoint alert's overrides, if applicable
|
||||
if len(endpointAlert.ProviderOverride) > 0 {
|
||||
if err = alertProvider.ValidateOverrides(ep.Group, endpointAlert); err != nil {
|
||||
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
|
||||
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ep.Key(), alertType, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,12 +658,12 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
for _, ee := range externalEndpoints {
|
||||
for alertIndex, endpointAlert := range ee.Alerts {
|
||||
if alertType == endpointAlert.Type {
|
||||
logr.Debugf("[config.validateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
|
||||
logr.Debugf("[config.ValidateAlertingConfig] Parsing alert %d with default alert for provider=%s in endpoint with key=%s", alertIndex, alertType, ee.Key())
|
||||
provider.MergeProviderDefaultAlertIntoEndpointAlert(alertProvider.GetDefaultAlert(), endpointAlert)
|
||||
// Validate the endpoint alert's overrides, if applicable
|
||||
if len(endpointAlert.ProviderOverride) > 0 {
|
||||
if err = alertProvider.ValidateOverrides(ee.Group, endpointAlert); err != nil {
|
||||
logr.Warnf("[config.validateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
|
||||
logr.Warnf("[config.ValidateAlertingConfig] endpoint with key=%s has invalid overrides for provider=%s: %s", ee.Key(), alertType, err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -507,7 +672,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
}
|
||||
validProviders = append(validProviders, alertType)
|
||||
} else {
|
||||
logr.Warnf("[config.validateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
|
||||
logr.Warnf("[config.ValidateAlertingConfig] Ignoring provider=%s due to error=%s", alertType, err.Error())
|
||||
invalidProviders = append(invalidProviders, alertType)
|
||||
alertingConfig.SetAlertingProviderToNil(alertProvider)
|
||||
}
|
||||
@@ -515,5 +680,19 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
|
||||
invalidProviders = append(invalidProviders, alertType)
|
||||
}
|
||||
}
|
||||
logr.Infof("[config.validateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
logr.Infof("[config.ValidateAlertingConfig] configuredProviders=%s; ignoredProviders=%s", validProviders, invalidProviders)
|
||||
}
|
||||
|
||||
func ValidateAndSetConcurrencyDefaults(config *Config) {
|
||||
if config.DisableMonitoringLock {
|
||||
config.Concurrency = 0
|
||||
logr.Warn("WARNING: The 'disable-monitoring-lock' configuration has been deprecated and will be removed in v6.0.0")
|
||||
logr.Warn("WARNING: Please set 'concurrency: 0' instead")
|
||||
logr.Debug("[config.ValidateAndSetConcurrencyDefaults] DisableMonitoringLock is true, setting unlimited (0) concurrency")
|
||||
} else if config.Concurrency <= 0 && !config.DisableMonitoringLock {
|
||||
config.Concurrency = DefaultConcurrency
|
||||
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Setting default concurrency to %d", config.Concurrency)
|
||||
} else {
|
||||
logr.Debugf("[config.ValidateAndSetConcurrencyDefaults] Using configured concurrency of %d", config.Concurrency)
|
||||
}
|
||||
}
|
||||
+629
-6
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/alerting/provider"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/awsses"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/custom"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/datadog"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/discord"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/email"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/gitea"
|
||||
@@ -20,22 +21,41 @@ import (
|
||||
"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/homeassistant"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ifttt"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ilert"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/incidentio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/line"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/matrix"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/mattermost"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/messagebird"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/newrelic"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/ntfy"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/opsgenie"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pagerduty"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/plivo"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/pushover"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/rocketchat"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/sendgrid"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signal"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/signl4"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/slack"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/splunk"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/squadcast"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teams"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/teamsworkflows"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/telegram"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/twilio"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/vonage"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/webex"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zapier"
|
||||
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/TwiN/gatus/v5/config/tunneling"
|
||||
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
|
||||
"github.com/TwiN/gatus/v5/config/web"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -118,7 +138,7 @@ endpoints:
|
||||
pathAndFiles: map[string]string{
|
||||
"config.yaml": "",
|
||||
},
|
||||
expectedError: ErrNoEndpointInConfig,
|
||||
expectedError: ErrNoEndpointOrSuiteInConfig,
|
||||
},
|
||||
{
|
||||
name: "dir-with-two-config-files",
|
||||
@@ -720,8 +740,8 @@ badconfig:
|
||||
if err == nil {
|
||||
t.Error("An error should've been returned")
|
||||
}
|
||||
if err != ErrNoEndpointInConfig {
|
||||
t.Error("The error returned should have been of type ErrNoEndpointInConfig")
|
||||
if err != ErrNoEndpointOrSuiteInConfig {
|
||||
t.Error("The error returned should have been of type ErrNoEndpointOrSuiteInConfig")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1833,7 +1853,7 @@ endpoints:
|
||||
if config.Security == nil {
|
||||
t.Fatal("config.Security shouldn't have been nil")
|
||||
}
|
||||
if !config.Security.IsValid() {
|
||||
if !config.Security.ValidateAndSetDefaults() {
|
||||
t.Error("Security config should've been valid")
|
||||
}
|
||||
if config.Security.Basic == nil {
|
||||
@@ -1876,8 +1896,8 @@ endpoints:
|
||||
|
||||
func TestParseAndValidateConfigBytesWithNoEndpoints(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(``))
|
||||
if !errors.Is(err, ErrNoEndpointInConfig) {
|
||||
t.Error("The error returned should have been of type ErrNoEndpointInConfig")
|
||||
if !errors.Is(err, ErrNoEndpointOrSuiteInConfig) {
|
||||
t.Error("The error returned should have been of type ErrNoEndpointOrSuiteInConfig")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1885,6 +1905,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
alertingConfig := &alerting.Config{
|
||||
AWSSimpleEmailService: &awsses.AlertProvider{},
|
||||
Custom: &custom.AlertProvider{},
|
||||
Datadog: &datadog.AlertProvider{},
|
||||
Discord: &discord.AlertProvider{},
|
||||
Email: &email.AlertProvider{},
|
||||
Gitea: &gitea.AlertProvider{},
|
||||
@@ -1892,19 +1913,35 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
GitLab: &gitlab.AlertProvider{},
|
||||
GoogleChat: &googlechat.AlertProvider{},
|
||||
Gotify: &gotify.AlertProvider{},
|
||||
HomeAssistant: &homeassistant.AlertProvider{},
|
||||
IFTTT: &ifttt.AlertProvider{},
|
||||
Ilert: &ilert.AlertProvider{},
|
||||
IncidentIO: &incidentio.AlertProvider{},
|
||||
JetBrainsSpace: &jetbrainsspace.AlertProvider{},
|
||||
Line: &line.AlertProvider{},
|
||||
Matrix: &matrix.AlertProvider{},
|
||||
Mattermost: &mattermost.AlertProvider{},
|
||||
Messagebird: &messagebird.AlertProvider{},
|
||||
NewRelic: &newrelic.AlertProvider{},
|
||||
Ntfy: &ntfy.AlertProvider{},
|
||||
Opsgenie: &opsgenie.AlertProvider{},
|
||||
PagerDuty: &pagerduty.AlertProvider{},
|
||||
Plivo: &plivo.AlertProvider{},
|
||||
Pushover: &pushover.AlertProvider{},
|
||||
RocketChat: &rocketchat.AlertProvider{},
|
||||
SendGrid: &sendgrid.AlertProvider{},
|
||||
Signal: &signal.AlertProvider{},
|
||||
SIGNL4: &signl4.AlertProvider{},
|
||||
Slack: &slack.AlertProvider{},
|
||||
Splunk: &splunk.AlertProvider{},
|
||||
Squadcast: &squadcast.AlertProvider{},
|
||||
Telegram: &telegram.AlertProvider{},
|
||||
Teams: &teams.AlertProvider{},
|
||||
TeamsWorkflows: &teamsworkflows.AlertProvider{},
|
||||
Twilio: &twilio.AlertProvider{},
|
||||
Vonage: &vonage.AlertProvider{},
|
||||
Webex: &webex.AlertProvider{},
|
||||
Zapier: &zapier.AlertProvider{},
|
||||
Zulip: &zulip.AlertProvider{},
|
||||
}
|
||||
scenarios := []struct {
|
||||
@@ -1913,6 +1950,7 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
}{
|
||||
{alertType: alert.TypeAWSSES, expected: alertingConfig.AWSSimpleEmailService},
|
||||
{alertType: alert.TypeCustom, expected: alertingConfig.Custom},
|
||||
{alertType: alert.TypeDatadog, expected: alertingConfig.Datadog},
|
||||
{alertType: alert.TypeDiscord, expected: alertingConfig.Discord},
|
||||
{alertType: alert.TypeEmail, expected: alertingConfig.Email},
|
||||
{alertType: alert.TypeGitea, expected: alertingConfig.Gitea},
|
||||
@@ -1920,19 +1958,35 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
{alertType: alert.TypeGitLab, expected: alertingConfig.GitLab},
|
||||
{alertType: alert.TypeGoogleChat, expected: alertingConfig.GoogleChat},
|
||||
{alertType: alert.TypeGotify, expected: alertingConfig.Gotify},
|
||||
{alertType: alert.TypeHomeAssistant, expected: alertingConfig.HomeAssistant},
|
||||
{alertType: alert.TypeIFTTT, expected: alertingConfig.IFTTT},
|
||||
{alertType: alert.TypeIlert, expected: alertingConfig.Ilert},
|
||||
{alertType: alert.TypeIncidentIO, expected: alertingConfig.IncidentIO},
|
||||
{alertType: alert.TypeJetBrainsSpace, expected: alertingConfig.JetBrainsSpace},
|
||||
{alertType: alert.TypeLine, expected: alertingConfig.Line},
|
||||
{alertType: alert.TypeMatrix, expected: alertingConfig.Matrix},
|
||||
{alertType: alert.TypeMattermost, expected: alertingConfig.Mattermost},
|
||||
{alertType: alert.TypeMessagebird, expected: alertingConfig.Messagebird},
|
||||
{alertType: alert.TypeNewRelic, expected: alertingConfig.NewRelic},
|
||||
{alertType: alert.TypeNtfy, expected: alertingConfig.Ntfy},
|
||||
{alertType: alert.TypeOpsgenie, expected: alertingConfig.Opsgenie},
|
||||
{alertType: alert.TypePagerDuty, expected: alertingConfig.PagerDuty},
|
||||
{alertType: alert.TypePlivo, expected: alertingConfig.Plivo},
|
||||
{alertType: alert.TypePushover, expected: alertingConfig.Pushover},
|
||||
{alertType: alert.TypeRocketChat, expected: alertingConfig.RocketChat},
|
||||
{alertType: alert.TypeSendGrid, expected: alertingConfig.SendGrid},
|
||||
{alertType: alert.TypeSignal, expected: alertingConfig.Signal},
|
||||
{alertType: alert.TypeSIGNL4, expected: alertingConfig.SIGNL4},
|
||||
{alertType: alert.TypeSlack, expected: alertingConfig.Slack},
|
||||
{alertType: alert.TypeSplunk, expected: alertingConfig.Splunk},
|
||||
{alertType: alert.TypeSquadcast, expected: alertingConfig.Squadcast},
|
||||
{alertType: alert.TypeTelegram, expected: alertingConfig.Telegram},
|
||||
{alertType: alert.TypeTeams, expected: alertingConfig.Teams},
|
||||
{alertType: alert.TypeTeamsWorkflows, expected: alertingConfig.TeamsWorkflows},
|
||||
{alertType: alert.TypeTwilio, expected: alertingConfig.Twilio},
|
||||
{alertType: alert.TypeVonage, expected: alertingConfig.Vonage},
|
||||
{alertType: alert.TypeWebex, expected: alertingConfig.Webex},
|
||||
{alertType: alert.TypeZapier, expected: alertingConfig.Zapier},
|
||||
{alertType: alert.TypeZulip, expected: alertingConfig.Zulip},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
@@ -2054,3 +2108,572 @@ func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithDuplicateKeysAcrossEntityTypes(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
shouldError bool
|
||||
expectedErr string
|
||||
config string
|
||||
}{
|
||||
{
|
||||
name: "endpoint-suite-same-key",
|
||||
shouldError: true,
|
||||
expectedErr: "duplicate key 'backend_test-api': suite 'backend_test-api' conflicts with endpoint 'backend_test-api'",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: test-api
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: test-api
|
||||
group: backend
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "endpoint-suite-different-keys",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: integration-tests
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "endpoint-external-endpoint-suite-unique-keys",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
external-endpoints:
|
||||
- name: monitoring-agent
|
||||
group: infrastructure
|
||||
token: "secret-token"
|
||||
heartbeat:
|
||||
interval: 5m
|
||||
|
||||
suites:
|
||||
- name: integration-tests
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "suite-with-same-key-as-external-endpoint",
|
||||
shouldError: true,
|
||||
expectedErr: "duplicate key 'monitoring_health-check': suite 'monitoring_health-check' conflicts with external endpoint 'monitoring_health-check'",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
external-endpoints:
|
||||
- name: health-check
|
||||
group: monitoring
|
||||
token: "secret-token"
|
||||
heartbeat:
|
||||
interval: 5m
|
||||
|
||||
suites:
|
||||
- name: health-check
|
||||
group: monitoring
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "endpoint-with-same-name-as-suite-endpoint-different-groups",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-health
|
||||
group: backend
|
||||
url: https://example.com/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: integration-suite
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: api-health
|
||||
url: https://example.com/api/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "endpoint-conflicting-with-suite-endpoint",
|
||||
shouldError: true,
|
||||
expectedErr: "duplicate key 'backend_api-health': endpoint 'backend_api-health' in suite 'backend_integration-suite' conflicts with endpoint 'backend_api-health'",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-health
|
||||
group: backend
|
||||
url: https://example.com/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: integration-suite
|
||||
group: backend
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: api-health
|
||||
url: https://example.com/api/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
|
||||
if scenario.shouldError {
|
||||
if err == nil {
|
||||
t.Error("should've returned an error")
|
||||
} else if scenario.expectedErr != "" && err.Error() != scenario.expectedErr {
|
||||
t.Errorf("expected error message '%s', got '%s'", scenario.expectedErr, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("shouldn't have returned an error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAndValidateConfigBytesWithSuites(t *testing.T) {
|
||||
scenarios := []struct {
|
||||
name string
|
||||
shouldError bool
|
||||
expectedErr string
|
||||
config string
|
||||
}{
|
||||
{
|
||||
name: "suite-with-no-name",
|
||||
shouldError: true,
|
||||
expectedErr: "invalid suite 'testing_': suite must have a name",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "suite-with-no-endpoints",
|
||||
shouldError: true,
|
||||
expectedErr: "invalid suite 'testing_empty-suite': suite must have at least one endpoint",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: empty-suite
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints: []`,
|
||||
},
|
||||
{
|
||||
name: "suite-with-duplicate-endpoint-names",
|
||||
shouldError: true,
|
||||
expectedErr: "invalid suite 'testing_duplicate-test': suite cannot have duplicate endpoint names: duplicate endpoint name 'step1'",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: duplicate-test
|
||||
group: testing
|
||||
interval: 30s
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test1
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: step1
|
||||
url: https://example.com/test2
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "suite-with-invalid-negative-timeout",
|
||||
shouldError: true,
|
||||
expectedErr: "invalid suite 'testing_negative-timeout-suite': suite timeout must be positive",
|
||||
config: `
|
||||
endpoints:
|
||||
- name: dummy
|
||||
url: https://example.com/dummy
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: negative-timeout-suite
|
||||
group: testing
|
||||
interval: 30s
|
||||
timeout: -5m
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "valid-suite-with-defaults",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: integration-test
|
||||
group: testing
|
||||
endpoints:
|
||||
- name: step1
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: step2
|
||||
url: https://example.com/validate
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "valid-suite-with-all-fields",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: full-integration-test
|
||||
group: testing
|
||||
enabled: true
|
||||
interval: 15m
|
||||
timeout: 10m
|
||||
context:
|
||||
base_url: "https://example.com"
|
||||
user_id: 12345
|
||||
endpoints:
|
||||
- name: authentication
|
||||
url: https://example.com/auth
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- name: user-profile
|
||||
url: https://example.com/profile
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
- "[BODY].user_id == 12345"`,
|
||||
},
|
||||
{
|
||||
name: "valid-suite-with-endpoint-inheritance",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: inheritance-test
|
||||
group: parent-group
|
||||
endpoints:
|
||||
- name: child-endpoint
|
||||
url: https://example.com/test
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
{
|
||||
name: "valid-suite-with-store-functionality",
|
||||
shouldError: false,
|
||||
config: `
|
||||
endpoints:
|
||||
- name: api-service
|
||||
group: backend
|
||||
url: https://example.com/api
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
|
||||
suites:
|
||||
- name: store-test
|
||||
group: testing
|
||||
endpoints:
|
||||
- name: get-token
|
||||
url: https://example.com/auth
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
store:
|
||||
auth_token: "[BODY].token"
|
||||
- name: use-token
|
||||
url: https://example.com/data
|
||||
headers:
|
||||
Authorization: "Bearer {auth_token}"
|
||||
conditions:
|
||||
- "[STATUS] == 200"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(scenario.config))
|
||||
if scenario.shouldError {
|
||||
if err == nil {
|
||||
t.Error("should've returned an error")
|
||||
} else if scenario.expectedErr != "" && err.Error() != scenario.expectedErr {
|
||||
t.Errorf("expected error message '%s', got '%s'", scenario.expectedErr, err.Error())
|
||||
}
|
||||
} else if err != nil {
|
||||
t.Errorf("shouldn't have returned an error, got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTunnelingConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid tunneling config",
|
||||
config: &Config{
|
||||
Tunneling: &tunneling.Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "test-endpoint",
|
||||
URL: "http://example.com/health",
|
||||
ClientConfig: &client.Config{
|
||||
Tunnel: "test",
|
||||
},
|
||||
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid tunnel reference in endpoint",
|
||||
config: &Config{
|
||||
Tunneling: &tunneling.Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "test-endpoint",
|
||||
URL: "http://example.com/health",
|
||||
ClientConfig: &client.Config{
|
||||
Tunnel: "nonexistent",
|
||||
},
|
||||
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "endpoint '_test-endpoint': tunnel 'nonexistent' not found in tunneling configuration",
|
||||
},
|
||||
{
|
||||
name: "invalid tunnel reference in suite endpoint",
|
||||
config: &Config{
|
||||
Tunneling: &tunneling.Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
Suites: []*suite.Suite{
|
||||
{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "suite-endpoint",
|
||||
URL: "http://example.com/health",
|
||||
ClientConfig: &client.Config{
|
||||
Tunnel: "invalid",
|
||||
},
|
||||
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "suite '_test-suite' endpoint '_suite-endpoint': tunnel 'invalid' not found in tunneling configuration",
|
||||
},
|
||||
{
|
||||
name: "no tunneling config",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "test-endpoint",
|
||||
URL: "http://example.com/health",
|
||||
Conditions: []endpoint.Condition{"[STATUS] == 200"},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateTunnelingConfig(tt.config)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("ValidateTunnelingConfig() expected error but got none")
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("ValidateTunnelingConfig() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ValidateTunnelingConfig() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveTunnelForClientConfig(t *testing.T) {
|
||||
config := &Config{
|
||||
Tunneling: &tunneling.Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := config.Tunneling.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to validate tunnel config: %v", err)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
clientConfig *client.Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid tunnel reference",
|
||||
clientConfig: &client.Config{
|
||||
Tunnel: "test",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid tunnel reference",
|
||||
clientConfig: &client.Config{
|
||||
Tunnel: "nonexistent",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "tunnel 'nonexistent' not found in tunneling configuration",
|
||||
},
|
||||
{
|
||||
name: "no tunnel reference",
|
||||
clientConfig: &client.Config{},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := resolveTunnelForClientConfig(config, tt.clientConfig)
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("resolveTunnelForClientConfig() expected error but got none")
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("resolveTunnelForClientConfig() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("resolveTunnelForClientConfig() unexpected error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+46
-162
@@ -7,82 +7,11 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/jsonpath"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/pattern"
|
||||
)
|
||||
|
||||
// Placeholders
|
||||
const (
|
||||
// StatusPlaceholder is a placeholder for a HTTP status.
|
||||
//
|
||||
// Values that could replace the placeholder: 200, 404, 500, ...
|
||||
StatusPlaceholder = "[STATUS]"
|
||||
|
||||
// IPPlaceholder is a placeholder for an IP.
|
||||
//
|
||||
// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...
|
||||
IPPlaceholder = "[IP]"
|
||||
|
||||
// DNSRCodePlaceholder is a placeholder for DNS_RCODE
|
||||
//
|
||||
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
|
||||
DNSRCodePlaceholder = "[DNS_RCODE]"
|
||||
|
||||
// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 1, 500, 1000, ...
|
||||
ResponseTimePlaceholder = "[RESPONSE_TIME]"
|
||||
|
||||
// BodyPlaceholder is a placeholder for the Body of the response
|
||||
//
|
||||
// Values that could replace the placeholder: {}, {"data":{"name":"john"}}, ...
|
||||
BodyPlaceholder = "[BODY]"
|
||||
|
||||
// ConnectedPlaceholder is a placeholder for whether a connection was successfully established.
|
||||
//
|
||||
// Values that could replace the placeholder: true, false
|
||||
ConnectedPlaceholder = "[CONNECTED]"
|
||||
|
||||
// CertificateExpirationPlaceholder is a placeholder for the duration before certificate expiration, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 4461677039 (~52 days)
|
||||
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
|
||||
|
||||
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
|
||||
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
|
||||
)
|
||||
|
||||
// Functions
|
||||
const (
|
||||
// LengthFunctionPrefix is the prefix for the length function
|
||||
//
|
||||
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
|
||||
LengthFunctionPrefix = "len("
|
||||
|
||||
// HasFunctionPrefix is the prefix for the has function
|
||||
//
|
||||
// Usage: has([BODY].errors) == true
|
||||
HasFunctionPrefix = "has("
|
||||
|
||||
// PatternFunctionPrefix is the prefix for the pattern function
|
||||
//
|
||||
// Usage: [IP] == pat(192.168.*.*)
|
||||
PatternFunctionPrefix = "pat("
|
||||
|
||||
// AnyFunctionPrefix is the prefix for the any function
|
||||
//
|
||||
// Usage: [IP] == any(1.1.1.1, 1.0.0.1)
|
||||
AnyFunctionPrefix = "any("
|
||||
|
||||
// FunctionSuffix is the suffix for all functions
|
||||
FunctionSuffix = ")"
|
||||
)
|
||||
|
||||
// Other constants
|
||||
const (
|
||||
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
|
||||
InvalidConditionElementSuffix = "(INVALID)"
|
||||
|
||||
// maximumLengthBeforeTruncatingWhenComparedWithPattern is the maximum length an element being compared to a
|
||||
// pattern can have.
|
||||
//
|
||||
@@ -97,50 +26,50 @@ type Condition string
|
||||
// Validate checks if the Condition is valid
|
||||
func (c Condition) Validate() error {
|
||||
r := &Result{}
|
||||
c.evaluate(r, false)
|
||||
c.evaluate(r, false, nil)
|
||||
if len(r.Errors) != 0 {
|
||||
return errors.New(r.Errors[0])
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// evaluate the Condition with the Result of the health check
|
||||
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool) bool {
|
||||
// evaluate the Condition with the Result and an optional context
|
||||
func (c Condition) evaluate(result *Result, dontResolveFailedConditions bool, context *gontext.Gontext) bool {
|
||||
condition := string(c)
|
||||
success := false
|
||||
conditionToDisplay := condition
|
||||
if strings.Contains(condition, " == ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, " == "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " == "), result, context)
|
||||
success = isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "==")
|
||||
}
|
||||
} else if strings.Contains(condition, " != ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(strings.Split(condition, " != "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveWithContext(strings.Split(condition, " != "), result, context)
|
||||
success = !isEqual(resolvedParameters[0], resolvedParameters[1])
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettify(parameters, resolvedParameters, "!=")
|
||||
}
|
||||
} else if strings.Contains(condition, " <= ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " <= "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " <= "), result, context)
|
||||
success = resolvedParameters[0] <= resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<=")
|
||||
}
|
||||
} else if strings.Contains(condition, " >= ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " >= "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " >= "), result, context)
|
||||
success = resolvedParameters[0] >= resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">=")
|
||||
}
|
||||
} else if strings.Contains(condition, " > ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " > "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " > "), result, context)
|
||||
success = resolvedParameters[0] > resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, ">")
|
||||
}
|
||||
} else if strings.Contains(condition, " < ") {
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumerical(strings.Split(condition, " < "), result)
|
||||
parameters, resolvedParameters := sanitizeAndResolveNumericalWithContext(strings.Split(condition, " < "), result, context)
|
||||
success = resolvedParameters[0] < resolvedParameters[1]
|
||||
if !success && !dontResolveFailedConditions {
|
||||
conditionToDisplay = prettifyNumericalParameters(parameters, resolvedParameters, "<")
|
||||
@@ -235,79 +164,29 @@ func isEqual(first, second string) bool {
|
||||
return first == second
|
||||
}
|
||||
|
||||
// sanitizeAndResolve sanitizes and resolves a list of elements and returns the list of parameters as well as a list
|
||||
// of resolved parameters
|
||||
func sanitizeAndResolve(elements []string, result *Result) ([]string, []string) {
|
||||
// sanitizeAndResolveWithContext sanitizes and resolves a list of elements with an optional context
|
||||
func sanitizeAndResolveWithContext(elements []string, result *Result, context *gontext.Gontext) ([]string, []string) {
|
||||
parameters := make([]string, len(elements))
|
||||
resolvedParameters := make([]string, len(elements))
|
||||
body := strings.TrimSpace(string(result.Body))
|
||||
for i, element := range elements {
|
||||
element = strings.TrimSpace(element)
|
||||
parameters[i] = element
|
||||
switch strings.ToUpper(element) {
|
||||
case StatusPlaceholder:
|
||||
element = strconv.Itoa(result.HTTPStatus)
|
||||
case IPPlaceholder:
|
||||
element = result.IP
|
||||
case ResponseTimePlaceholder:
|
||||
element = strconv.Itoa(int(result.Duration.Milliseconds()))
|
||||
case BodyPlaceholder:
|
||||
element = body
|
||||
case DNSRCodePlaceholder:
|
||||
element = result.DNSRCode
|
||||
case ConnectedPlaceholder:
|
||||
element = strconv.FormatBool(result.Connected)
|
||||
case CertificateExpirationPlaceholder:
|
||||
element = strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10)
|
||||
case DomainExpirationPlaceholder:
|
||||
element = strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10)
|
||||
default:
|
||||
// if contains the BodyPlaceholder, then evaluate json path
|
||||
if strings.Contains(element, BodyPlaceholder) {
|
||||
checkingForLength := false
|
||||
checkingForExistence := false
|
||||
if strings.HasPrefix(element, LengthFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
checkingForLength = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, LengthFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
if strings.HasPrefix(element, HasFunctionPrefix) && strings.HasSuffix(element, FunctionSuffix) {
|
||||
checkingForExistence = true
|
||||
element = strings.TrimSuffix(strings.TrimPrefix(element, HasFunctionPrefix), FunctionSuffix)
|
||||
}
|
||||
resolvedElement, resolvedElementLength, err := jsonpath.Eval(strings.TrimPrefix(strings.TrimPrefix(element, BodyPlaceholder), "."), result.Body)
|
||||
if checkingForExistence {
|
||||
if err != nil {
|
||||
element = "false"
|
||||
} else {
|
||||
element = "true"
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
if err.Error() != "unexpected end of JSON input" {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
if checkingForLength {
|
||||
element = LengthFunctionPrefix + element + FunctionSuffix + " " + InvalidConditionElementSuffix
|
||||
} else {
|
||||
element = element + " " + InvalidConditionElementSuffix
|
||||
}
|
||||
} else {
|
||||
if checkingForLength {
|
||||
element = strconv.Itoa(resolvedElementLength)
|
||||
} else {
|
||||
element = resolvedElement
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use the unified ResolvePlaceholder function
|
||||
resolved, err := ResolvePlaceholder(element, result, context)
|
||||
if err != nil {
|
||||
// If there's an error, add it to the result
|
||||
result.AddError(err.Error())
|
||||
resolvedParameters[i] = element + " " + InvalidConditionElementSuffix
|
||||
} else {
|
||||
resolvedParameters[i] = resolved
|
||||
}
|
||||
resolvedParameters[i] = element
|
||||
}
|
||||
return parameters, resolvedParameters
|
||||
}
|
||||
|
||||
func sanitizeAndResolveNumerical(list []string, result *Result) (parameters []string, resolvedNumericalParameters []int64) {
|
||||
parameters, resolvedParameters := sanitizeAndResolve(list, result)
|
||||
func sanitizeAndResolveNumericalWithContext(list []string, result *Result, context *gontext.Gontext) (parameters []string, resolvedNumericalParameters []int64) {
|
||||
parameters, resolvedParameters := sanitizeAndResolveWithContext(list, result, context)
|
||||
for _, element := range resolvedParameters {
|
||||
if duration, err := time.ParseDuration(element); duration != 0 && err == nil {
|
||||
// If the string is a duration, convert it to milliseconds
|
||||
@@ -335,30 +214,35 @@ func prettifyNumericalParameters(parameters []string, resolvedParameters []int64
|
||||
|
||||
// prettify returns a string representation of a condition with its parameters resolved between parentheses
|
||||
func prettify(parameters []string, resolvedParameters []string, operator string) string {
|
||||
// Since, in the event of an invalid path, the resolvedParameters also contain the condition itself,
|
||||
// we'll return the resolvedParameters as-is.
|
||||
if strings.HasSuffix(resolvedParameters[0], InvalidConditionElementSuffix) || strings.HasSuffix(resolvedParameters[1], InvalidConditionElementSuffix) {
|
||||
return resolvedParameters[0] + " " + operator + " " + resolvedParameters[1]
|
||||
}
|
||||
// If using the pattern function, truncate the parameter it's being compared to if said parameter is long enough
|
||||
// Handle pattern function truncation first
|
||||
if strings.HasPrefix(parameters[0], PatternFunctionPrefix) && strings.HasSuffix(parameters[0], FunctionSuffix) && len(resolvedParameters[1]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[1] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[1])
|
||||
}
|
||||
if strings.HasPrefix(parameters[1], PatternFunctionPrefix) && strings.HasSuffix(parameters[1], FunctionSuffix) && len(resolvedParameters[0]) > maximumLengthBeforeTruncatingWhenComparedWithPattern {
|
||||
resolvedParameters[0] = fmt.Sprintf("%.25s...(truncated)", resolvedParameters[0])
|
||||
}
|
||||
// First element is a placeholder
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] == resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1]
|
||||
// Determine the state of each parameter
|
||||
leftChanged := parameters[0] != resolvedParameters[0]
|
||||
rightChanged := parameters[1] != resolvedParameters[1]
|
||||
leftInvalid := resolvedParameters[0] == parameters[0]+" "+InvalidConditionElementSuffix
|
||||
rightInvalid := resolvedParameters[1] == parameters[1]+" "+InvalidConditionElementSuffix
|
||||
// Build the output based on what was resolved
|
||||
var left, right string
|
||||
// Format left side
|
||||
if leftChanged && !leftInvalid {
|
||||
left = parameters[0] + " (" + resolvedParameters[0] + ")"
|
||||
} else if leftInvalid {
|
||||
left = resolvedParameters[0] // Already has (INVALID)
|
||||
} else {
|
||||
left = parameters[0] // Unchanged
|
||||
}
|
||||
// Second element is a placeholder
|
||||
if parameters[0] == resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
|
||||
return parameters[0] + " " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
// Format right side
|
||||
if rightChanged && !rightInvalid {
|
||||
right = parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
} else if rightInvalid {
|
||||
right = resolvedParameters[1] // Already has (INVALID)
|
||||
} else {
|
||||
right = parameters[1] // Unchanged
|
||||
}
|
||||
// Both elements are placeholders...?
|
||||
if parameters[0] != resolvedParameters[0] && parameters[1] != resolvedParameters[1] {
|
||||
return parameters[0] + " (" + resolvedParameters[0] + ") " + operator + " " + parameters[1] + " (" + resolvedParameters[1] + ")"
|
||||
}
|
||||
// Neither elements are placeholders
|
||||
return parameters[0] + " " + operator + " " + parameters[1]
|
||||
return left + " " + operator + " " + right
|
||||
}
|
||||
@@ -8,7 +8,7 @@ func BenchmarkCondition_evaluateWithBodyStringAny(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -17,7 +17,7 @@ func BenchmarkCondition_evaluateWithBodyStringAnyFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == any(john.doe, jane.doe)")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -26,7 +26,7 @@ func BenchmarkCondition_evaluateWithBodyString(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -35,7 +35,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailure(b *testing.B) {
|
||||
condition := Condition("[BODY].name == john.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -44,7 +44,7 @@ func BenchmarkCondition_evaluateWithBodyStringFailureInvalidPath(b *testing.B) {
|
||||
condition := Condition("[BODY].user.name == bob.doe")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -53,7 +53,7 @@ func BenchmarkCondition_evaluateWithBodyStringLen(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"john.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -62,7 +62,7 @@ func BenchmarkCondition_evaluateWithBodyStringLenFailure(b *testing.B) {
|
||||
condition := Condition("len([BODY].name) == 8")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{Body: []byte("{\"name\": \"bob.doe\"}")}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -71,7 +71,7 @@ func BenchmarkCondition_evaluateWithStatus(b *testing.B) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{HTTPStatus: 200}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -80,7 +80,7 @@ func BenchmarkCondition_evaluateWithStatusFailure(b *testing.B) {
|
||||
condition := Condition("[STATUS] == 200")
|
||||
for n := 0; n < b.N; n++ {
|
||||
result := &Result{HTTPStatus: 400}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
}
|
||||
b.ReportAllocs()
|
||||
}
|
||||
@@ -6,6 +6,8 @@ import (
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
)
|
||||
|
||||
func TestCondition_Validate(t *testing.T) {
|
||||
@@ -755,7 +757,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.Name, func(t *testing.T) {
|
||||
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions)
|
||||
scenario.Condition.evaluate(scenario.Result, scenario.DontResolveFailedConditions, nil)
|
||||
if scenario.Result.ConditionResults[0].Success != scenario.ExpectedSuccess {
|
||||
t.Errorf("Condition '%s' should have been success=%v", scenario.Condition, scenario.ExpectedSuccess)
|
||||
}
|
||||
@@ -769,7 +771,7 @@ func TestCondition_evaluate(t *testing.T) {
|
||||
func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||
condition := Condition("[STATUS] ? 201")
|
||||
result := &Result{HTTPStatus: 201}
|
||||
condition.evaluate(result, false)
|
||||
condition.evaluate(result, false, nil)
|
||||
if result.Success {
|
||||
t.Error("condition was invalid, result should've been a failure")
|
||||
}
|
||||
@@ -777,3 +779,77 @@ func TestCondition_evaluateWithInvalidOperator(t *testing.T) {
|
||||
t.Error("condition was invalid, result should've had an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithInvalidContextPlaceholder(t *testing.T) {
|
||||
// Test case: Suite endpoint with invalid context placeholder
|
||||
// This should display the original placeholder names with resolved values
|
||||
condition := Condition("[STATUS] == [CONTEXT].expected_statusz")
|
||||
result := &Result{HTTPStatus: 200}
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
// Note: expected_statusz is not in the context (typo - should be expected_status)
|
||||
"expected_status": 200,
|
||||
"max_response_time": 5000,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx) // false = don't skip resolution (default)
|
||||
if success {
|
||||
t.Error("Condition should have failed because [CONTEXT].expected_statusz doesn't exist")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// The expected format should preserve the placeholder names
|
||||
expectedDisplay := "[STATUS] (200) == [CONTEXT].expected_statusz (INVALID)"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display for failed context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithValidContextPlaceholder(t *testing.T) {
|
||||
// Test case: Suite endpoint with valid context placeholder
|
||||
condition := Condition("[STATUS] == [CONTEXT].expected_status")
|
||||
result := &Result{HTTPStatus: 200}
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
"expected_status": 200,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx)
|
||||
if !success {
|
||||
t.Error("Condition should have succeeded")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// For successful conditions, just the original condition is shown
|
||||
expectedDisplay := "[STATUS] == [CONTEXT].expected_status"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display for successful context placeholder\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConditionEvaluateWithMixedValidAndInvalidContext(t *testing.T) {
|
||||
// Test case: One valid placeholder, one invalid
|
||||
// Note: For numerical comparisons, invalid placeholders that can't be parsed as numbers
|
||||
// default to 0 due to sanitizeAndResolveNumericalWithContext's behavior
|
||||
condition := Condition("[RESPONSE_TIME] < [CONTEXT].invalid_key")
|
||||
result := &Result{Duration: 100 * 1000000} // 100ms in nanoseconds
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
"valid_key": 5000,
|
||||
})
|
||||
// Simulate suite endpoint evaluation with context
|
||||
success := condition.evaluate(result, false, ctx)
|
||||
if success {
|
||||
t.Error("Condition should have failed because [CONTEXT].invalid_key doesn't exist")
|
||||
}
|
||||
if len(result.ConditionResults) == 0 {
|
||||
t.Fatal("No condition results found")
|
||||
}
|
||||
actualDisplay := result.ConditionResults[0].Condition
|
||||
// For numerical comparisons, invalid context placeholders become 0
|
||||
expectedDisplay := "[RESPONSE_TIME] (100) < [CONTEXT].invalid_key (0)"
|
||||
if actualDisplay != expectedDisplay {
|
||||
t.Errorf("Incorrect condition display\nExpected: %s\nActual: %s", expectedDisplay, actualDisplay)
|
||||
}
|
||||
}
|
||||
+99
-18
@@ -21,6 +21,8 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
sshconfig "github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/config/key"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
@@ -134,6 +136,18 @@ type Endpoint struct {
|
||||
|
||||
// LastReminderSent is the time at which the last reminder was sent for this endpoint.
|
||||
LastReminderSent time.Time `yaml:"-"`
|
||||
|
||||
///////////////////////
|
||||
// SUITE-ONLY FIELDS //
|
||||
///////////////////////
|
||||
|
||||
// Store is a map of values to extract from the result and store in the suite context
|
||||
// This field is only used when the endpoint is part of a suite
|
||||
Store map[string]string `yaml:"store,omitempty"`
|
||||
|
||||
// AlwaysRun defines whether to execute this endpoint even if previous endpoints in the suite failed
|
||||
// This field is only used when the endpoint is part of a suite
|
||||
AlwaysRun bool `yaml:"always-run,omitempty"`
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the endpoint is enabled or not
|
||||
@@ -255,7 +269,7 @@ func (e *Endpoint) DisplayName() string {
|
||||
|
||||
// Key returns the unique key for the Endpoint
|
||||
func (e *Endpoint) Key() string {
|
||||
return ConvertGroupAndEndpointNameToKey(e.Group, e.Name)
|
||||
return key.ConvertGroupAndNameToKey(e.Group, e.Name)
|
||||
}
|
||||
|
||||
// Close HTTP connections between watchdog and endpoints to avoid dangling socket file descriptors
|
||||
@@ -269,16 +283,26 @@ func (e *Endpoint) Close() {
|
||||
|
||||
// EvaluateHealth sends a request to the endpoint's URL and evaluates the conditions of the endpoint.
|
||||
func (e *Endpoint) EvaluateHealth() *Result {
|
||||
return e.EvaluateHealthWithContext(nil)
|
||||
}
|
||||
|
||||
// EvaluateHealthWithContext sends a request to the endpoint's URL with context support and evaluates the conditions
|
||||
func (e *Endpoint) EvaluateHealthWithContext(context *gontext.Gontext) *Result {
|
||||
result := &Result{Success: true, Errors: []string{}}
|
||||
// Preprocess the endpoint with context if provided
|
||||
processedEndpoint := e
|
||||
if context != nil {
|
||||
processedEndpoint = e.preprocessWithContext(result, context)
|
||||
}
|
||||
// Parse or extract hostname from URL
|
||||
if e.DNSConfig != nil {
|
||||
result.Hostname = strings.TrimSuffix(e.URL, ":53")
|
||||
} else if e.Type() == TypeICMP {
|
||||
if processedEndpoint.DNSConfig != nil {
|
||||
result.Hostname = strings.TrimSuffix(processedEndpoint.URL, ":53")
|
||||
} else if processedEndpoint.Type() == TypeICMP {
|
||||
// To handle IPv6 addresses, we need to handle the hostname differently here. This is to avoid, for instance,
|
||||
// "1111:2222:3333::4444" being displayed as "1111:2222:3333:" because :4444 would be interpreted as a port.
|
||||
result.Hostname = strings.TrimPrefix(e.URL, "icmp://")
|
||||
result.Hostname = strings.TrimPrefix(processedEndpoint.URL, "icmp://")
|
||||
} else {
|
||||
urlObject, err := url.Parse(e.URL)
|
||||
urlObject, err := url.Parse(processedEndpoint.URL)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
} else {
|
||||
@@ -287,11 +311,11 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
}
|
||||
}
|
||||
// Retrieve IP if necessary
|
||||
if e.needsToRetrieveIP() {
|
||||
e.getIP(result)
|
||||
if processedEndpoint.needsToRetrieveIP() {
|
||||
processedEndpoint.getIP(result)
|
||||
}
|
||||
// Retrieve domain expiration if necessary
|
||||
if e.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
|
||||
if processedEndpoint.needsToRetrieveDomainExpiration() && len(result.Hostname) > 0 {
|
||||
var err error
|
||||
if result.DomainExpiration, err = client.GetDomainExpiration(result.Hostname); err != nil {
|
||||
result.AddError(err.Error())
|
||||
@@ -299,42 +323,91 @@ func (e *Endpoint) EvaluateHealth() *Result {
|
||||
}
|
||||
// Call the endpoint (if there's no errors)
|
||||
if len(result.Errors) == 0 {
|
||||
e.call(result)
|
||||
processedEndpoint.call(result)
|
||||
} else {
|
||||
result.Success = false
|
||||
}
|
||||
// Evaluate the conditions
|
||||
for _, condition := range e.Conditions {
|
||||
success := condition.evaluate(result, e.UIConfig.DontResolveFailedConditions)
|
||||
for _, condition := range processedEndpoint.Conditions {
|
||||
success := condition.evaluate(result, processedEndpoint.UIConfig.DontResolveFailedConditions, context)
|
||||
if !success {
|
||||
result.Success = false
|
||||
}
|
||||
}
|
||||
result.Timestamp = time.Now()
|
||||
// Clean up parameters that we don't need to keep in the results
|
||||
if e.UIConfig.HideURL {
|
||||
if processedEndpoint.UIConfig.HideURL {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, e.URL, "<redacted>")
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, processedEndpoint.URL, "<redacted>")
|
||||
}
|
||||
}
|
||||
if e.UIConfig.HideHostname {
|
||||
if processedEndpoint.UIConfig.HideHostname {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.Hostname, "<redacted>")
|
||||
}
|
||||
result.Hostname = "" // remove it from the result so it doesn't get exposed
|
||||
}
|
||||
if e.UIConfig.HidePort && len(result.port) > 0 {
|
||||
if processedEndpoint.UIConfig.HidePort && len(result.port) > 0 {
|
||||
for errIdx, errorString := range result.Errors {
|
||||
result.Errors[errIdx] = strings.ReplaceAll(errorString, result.port, "<redacted>")
|
||||
}
|
||||
result.port = ""
|
||||
}
|
||||
if e.UIConfig.HideConditions {
|
||||
if processedEndpoint.UIConfig.HideConditions {
|
||||
result.ConditionResults = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// preprocessWithContext creates a copy of the endpoint with context placeholders replaced
|
||||
func (e *Endpoint) preprocessWithContext(result *Result, context *gontext.Gontext) *Endpoint {
|
||||
// Create a deep copy of the endpoint
|
||||
processed := &Endpoint{}
|
||||
*processed = *e
|
||||
var err error
|
||||
// Replace context placeholders in URL
|
||||
if processed.URL, err = replaceContextPlaceholders(e.URL, context); err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
// Replace context placeholders in Body
|
||||
if processed.Body, err = replaceContextPlaceholders(e.Body, context); err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
// Replace context placeholders in Headers
|
||||
if e.Headers != nil {
|
||||
processed.Headers = make(map[string]string)
|
||||
for k, v := range e.Headers {
|
||||
if processed.Headers[k], err = replaceContextPlaceholders(v, context); err != nil {
|
||||
result.AddError(err.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
return processed
|
||||
}
|
||||
|
||||
// replaceContextPlaceholders replaces [CONTEXT].path placeholders with actual values
|
||||
func replaceContextPlaceholders(input string, ctx *gontext.Gontext) (string, error) {
|
||||
if ctx == nil {
|
||||
return input, nil
|
||||
}
|
||||
var contextErrors []string
|
||||
contextRegex := regexp.MustCompile(`\[CONTEXT\]\.[\w\.\-]+`)
|
||||
result := contextRegex.ReplaceAllStringFunc(input, func(match string) string {
|
||||
// Extract the path after [CONTEXT].
|
||||
path := strings.TrimPrefix(match, "[CONTEXT].")
|
||||
value, err := ctx.Get(path)
|
||||
if err != nil {
|
||||
contextErrors = append(contextErrors, fmt.Sprintf("path '%s' not found", path))
|
||||
return match // Keep placeholder for error reporting
|
||||
}
|
||||
return fmt.Sprintf("%v", value)
|
||||
})
|
||||
if len(contextErrors) > 0 {
|
||||
return result, fmt.Errorf("context placeholder resolution failed: %s", strings.Join(contextErrors, ", "))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (e *Endpoint) getParsedBody() string {
|
||||
body := e.Body
|
||||
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", e.Name)
|
||||
@@ -492,13 +565,21 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
|
||||
return request
|
||||
}
|
||||
|
||||
// needsToReadBody checks if there's any condition that requires the response Body to be read
|
||||
// needsToReadBody checks if there's any condition or store mapping that requires the response Body to be read
|
||||
func (e *Endpoint) needsToReadBody() bool {
|
||||
for _, condition := range e.Conditions {
|
||||
if condition.hasBodyPlaceholder() {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// Check store values for body placeholders
|
||||
if e.Store != nil {
|
||||
for _, value := range e.Store {
|
||||
if strings.Contains(value, BodyPlaceholder) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ssh"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/ui"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
"github.com/TwiN/gatus/v5/test"
|
||||
)
|
||||
@@ -913,6 +914,40 @@ func TestEndpoint_needsToReadBody(t *testing.T) {
|
||||
if !(&Endpoint{Conditions: []Condition{bodyConditionWithLength, statusCondition}}).needsToReadBody() {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
// Test store configuration with body placeholder
|
||||
storeWithBodyPlaceholder := map[string]string{
|
||||
"token": "[BODY].accessToken",
|
||||
}
|
||||
if !(&Endpoint{
|
||||
Conditions: []Condition{statusCondition},
|
||||
Store: storeWithBodyPlaceholder,
|
||||
}).needsToReadBody() {
|
||||
t.Error("expected true when store has body placeholder, got false")
|
||||
}
|
||||
// Test store configuration without body placeholder
|
||||
storeWithoutBodyPlaceholder := map[string]string{
|
||||
"status": "[STATUS]",
|
||||
}
|
||||
if (&Endpoint{
|
||||
Conditions: []Condition{statusCondition},
|
||||
Store: storeWithoutBodyPlaceholder,
|
||||
}).needsToReadBody() {
|
||||
t.Error("expected false when store has no body placeholder, got true")
|
||||
}
|
||||
// Test empty store
|
||||
if (&Endpoint{
|
||||
Conditions: []Condition{statusCondition},
|
||||
Store: map[string]string{},
|
||||
}).needsToReadBody() {
|
||||
t.Error("expected false when store is empty, got true")
|
||||
}
|
||||
// Test nil store
|
||||
if (&Endpoint{
|
||||
Conditions: []Condition{statusCondition},
|
||||
Store: nil,
|
||||
}).needsToReadBody() {
|
||||
t.Error("expected false when store is nil, got true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_needsToRetrieveDomainExpiration(t *testing.T) {
|
||||
@@ -932,3 +967,484 @@ func TestEndpoint_needsToRetrieveIP(t *testing.T) {
|
||||
t.Error("expected true, got false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEndpoint_preprocessWithContext(t *testing.T) {
|
||||
// Import the gontext package for creating test contexts
|
||||
// This test thoroughly exercises the replaceContextPlaceholders function
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *Endpoint
|
||||
context map[string]interface{}
|
||||
expectedURL string
|
||||
expectedBody string
|
||||
expectedHeaders map[string]string
|
||||
expectedErrorCount int
|
||||
expectedErrorContains []string
|
||||
}{
|
||||
{
|
||||
name: "successful_url_replacement",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345",
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/12345",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "successful_body_replacement",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: `{"userId": "[CONTEXT].userId", "action": "update"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "67890",
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: `{"userId": "67890", "action": "update"}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "successful_header_replacement",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: "",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer [CONTEXT].token",
|
||||
"X-User-ID": "[CONTEXT].userId",
|
||||
},
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"token": "abc123token",
|
||||
"userId": "user123",
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: "",
|
||||
expectedHeaders: map[string]string{
|
||||
"Authorization": "Bearer abc123token",
|
||||
"X-User-ID": "user123",
|
||||
},
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "multiple_placeholders_in_url",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://[CONTEXT].host/api/v[CONTEXT].version/users/[CONTEXT].userId",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"host": "api.example.com",
|
||||
"version": "2",
|
||||
"userId": "12345",
|
||||
},
|
||||
expectedURL: "https://api.example.com/api/v2/users/12345",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "nested_context_path",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].user.id",
|
||||
Body: `{"name": "[CONTEXT].user.name"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": "nested123",
|
||||
"name": "John Doe",
|
||||
},
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/nested123",
|
||||
expectedBody: `{"name": "John Doe"}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "url_context_not_found",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].missingUserId",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345", // different key
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].missingUserId",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'missingUserId' not found"},
|
||||
},
|
||||
{
|
||||
name: "body_context_not_found",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: `{"userId": "[CONTEXT].missingUserId"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345", // different key
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: `{"userId": "[CONTEXT].missingUserId"}`,
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'missingUserId' not found"},
|
||||
},
|
||||
{
|
||||
name: "header_context_not_found",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: "",
|
||||
Headers: map[string]string{
|
||||
"Authorization": "Bearer [CONTEXT].missingToken",
|
||||
},
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"token": "validtoken", // different key
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: "",
|
||||
expectedHeaders: map[string]string{
|
||||
"Authorization": "Bearer [CONTEXT].missingToken",
|
||||
},
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'missingToken' not found"},
|
||||
},
|
||||
{
|
||||
name: "multiple_missing_context_paths",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId",
|
||||
Body: `{"token": "[CONTEXT].missingToken"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"validKey": "validValue",
|
||||
},
|
||||
expectedURL: "https://[CONTEXT].missingHost/users/[CONTEXT].missingUserId",
|
||||
expectedBody: `{"token": "[CONTEXT].missingToken"}`,
|
||||
expectedErrorCount: 2, // 1 for URL (both placeholders), 1 for Body
|
||||
expectedErrorContains: []string{
|
||||
"path 'missingHost' not found",
|
||||
"path 'missingUserId' not found",
|
||||
"path 'missingToken' not found",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "mixed_valid_and_invalid_placeholders",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId/posts/[CONTEXT].missingPostId",
|
||||
Body: `{"userId": "[CONTEXT].userId", "action": "[CONTEXT].missingAction"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345",
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/12345/posts/[CONTEXT].missingPostId",
|
||||
expectedBody: `{"userId": "12345", "action": "[CONTEXT].missingAction"}`,
|
||||
expectedErrorCount: 2,
|
||||
expectedErrorContains: []string{
|
||||
"path 'missingPostId' not found",
|
||||
"path 'missingAction' not found",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nil_context",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
Body: "",
|
||||
},
|
||||
context: nil,
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "empty_context",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{},
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].userId",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'userId' not found"},
|
||||
},
|
||||
{
|
||||
name: "special_characters_in_context_values",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/search?q=[CONTEXT].query",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"query": "hello world & special chars!",
|
||||
},
|
||||
expectedURL: "https://api.example.com/search?q=hello world & special chars!",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "numeric_context_values",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].userId/limit/[CONTEXT].limit",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": 12345,
|
||||
"limit": 100,
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/12345/limit/100",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "boolean_context_values",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: `{"enabled": [CONTEXT].enabled, "active": [CONTEXT].active}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"enabled": true,
|
||||
"active": false,
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: `{"enabled": true, "active": false}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "no_context_placeholders",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/health",
|
||||
Body: `{"status": "check"}`,
|
||||
Headers: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"userId": "12345",
|
||||
},
|
||||
expectedURL: "https://api.example.com/health",
|
||||
expectedBody: `{"status": "check"}`,
|
||||
expectedHeaders: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "deeply_nested_context_path",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].response.data.user.id",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"response": map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": "deep123",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/deep123",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid_nested_context_path",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].response.missing.path",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"response": map[string]interface{}{
|
||||
"data": "value",
|
||||
},
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].response.missing.path",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 1,
|
||||
expectedErrorContains: []string{"path 'response.missing.path' not found"},
|
||||
},
|
||||
{
|
||||
name: "hyphen_support_in_simple_keys",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].user-id",
|
||||
Body: `{"api-key": "[CONTEXT].api-key", "user-name": "[CONTEXT].user-name"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"user-id": "user-12345",
|
||||
"api-key": "key-abcdef",
|
||||
"user-name": "john-doe",
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/user-12345",
|
||||
expectedBody: `{"api-key": "key-abcdef", "user-name": "john-doe"}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "hyphen_support_in_headers",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: "",
|
||||
Headers: map[string]string{
|
||||
"X-API-Key": "[CONTEXT].api-key",
|
||||
"X-User-ID": "[CONTEXT].user-id",
|
||||
"Content-Type": "[CONTEXT].content-type",
|
||||
},
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"api-key": "secret-key-123",
|
||||
"user-id": "user-456",
|
||||
"content-type": "application-json",
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: "",
|
||||
expectedHeaders: map[string]string{
|
||||
"X-API-Key": "secret-key-123",
|
||||
"X-User-ID": "user-456",
|
||||
"Content-Type": "application-json",
|
||||
},
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "mixed_hyphens_underscores_and_dots",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/[CONTEXT].service-name/[CONTEXT].user_data.user-id",
|
||||
Body: `{"tenant-id": "[CONTEXT].tenant_config.tenant-id"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"service-name": "auth-service",
|
||||
"user_data": map[string]interface{}{
|
||||
"user-id": "user-789",
|
||||
},
|
||||
"tenant_config": map[string]interface{}{
|
||||
"tenant-id": "tenant-abc-123",
|
||||
},
|
||||
},
|
||||
expectedURL: "https://api.example.com/auth-service/user-789",
|
||||
expectedBody: `{"tenant-id": "tenant-abc-123"}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "hyphen_in_nested_paths",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].auth-response.user-data.profile-id",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"auth-response": map[string]interface{}{
|
||||
"user-data": map[string]interface{}{
|
||||
"profile-id": "profile-xyz-789",
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/profile-xyz-789",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "missing_hyphenated_context_key",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/users/[CONTEXT].missing-user-id",
|
||||
Body: `{"api-key": "[CONTEXT].missing-api-key"}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"user-id": "valid-user", // different key
|
||||
},
|
||||
expectedURL: "https://api.example.com/users/[CONTEXT].missing-user-id",
|
||||
expectedBody: `{"api-key": "[CONTEXT].missing-api-key"}`,
|
||||
expectedErrorCount: 2,
|
||||
expectedErrorContains: []string{"path 'missing-user-id' not found", "path 'missing-api-key' not found"},
|
||||
},
|
||||
{
|
||||
name: "multiple_hyphens_in_single_key",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/[CONTEXT].multi-hyphen-key-name",
|
||||
Body: "",
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"multi-hyphen-key-name": "value-with-multiple-hyphens",
|
||||
},
|
||||
expectedURL: "https://api.example.com/value-with-multiple-hyphens",
|
||||
expectedBody: "",
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "hyphens_with_numeric_values",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com/limit/[CONTEXT].max-items",
|
||||
Body: `{"timeout-ms": [CONTEXT].timeout-ms, "retry-count": [CONTEXT].retry-count}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"max-items": 100,
|
||||
"timeout-ms": 5000,
|
||||
"retry-count": 3,
|
||||
},
|
||||
expectedURL: "https://api.example.com/limit/100",
|
||||
expectedBody: `{"timeout-ms": 5000, "retry-count": 3}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
{
|
||||
name: "hyphens_with_boolean_values",
|
||||
endpoint: &Endpoint{
|
||||
URL: "https://api.example.com",
|
||||
Body: `{"enable-feature": [CONTEXT].enable-feature, "disable-cache": [CONTEXT].disable-cache}`,
|
||||
},
|
||||
context: map[string]interface{}{
|
||||
"enable-feature": true,
|
||||
"disable-cache": false,
|
||||
},
|
||||
expectedURL: "https://api.example.com",
|
||||
expectedBody: `{"enable-feature": true, "disable-cache": false}`,
|
||||
expectedErrorCount: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Import gontext package for creating context
|
||||
var ctx *gontext.Gontext
|
||||
if tt.context != nil {
|
||||
ctx = gontext.New(tt.context)
|
||||
}
|
||||
// Create a new Result to capture errors
|
||||
result := &Result{}
|
||||
// Call preprocessWithContext
|
||||
processed := tt.endpoint.preprocessWithContext(result, ctx)
|
||||
// Verify URL
|
||||
if processed.URL != tt.expectedURL {
|
||||
t.Errorf("URL mismatch:\nexpected: %s\nactual: %s", tt.expectedURL, processed.URL)
|
||||
}
|
||||
// Verify Body
|
||||
if processed.Body != tt.expectedBody {
|
||||
t.Errorf("Body mismatch:\nexpected: %s\nactual: %s", tt.expectedBody, processed.Body)
|
||||
}
|
||||
// Verify Headers
|
||||
if tt.expectedHeaders != nil {
|
||||
if processed.Headers == nil {
|
||||
t.Error("Expected headers but got nil")
|
||||
} else {
|
||||
for key, expectedValue := range tt.expectedHeaders {
|
||||
if actualValue, exists := processed.Headers[key]; !exists {
|
||||
t.Errorf("Expected header %s not found", key)
|
||||
} else if actualValue != expectedValue {
|
||||
t.Errorf("Header %s mismatch:\nexpected: %s\nactual: %s", key, expectedValue, actualValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Verify error count
|
||||
if len(result.Errors) != tt.expectedErrorCount {
|
||||
t.Errorf("Error count mismatch:\nexpected: %d\nactual: %d\nerrors: %v", tt.expectedErrorCount, len(result.Errors), result.Errors)
|
||||
}
|
||||
// Verify error messages contain expected strings
|
||||
if tt.expectedErrorContains != nil {
|
||||
actualErrors := strings.Join(result.Errors, " ")
|
||||
for _, expectedError := range tt.expectedErrorContains {
|
||||
if !strings.Contains(actualErrors, expectedError) {
|
||||
t.Errorf("Expected error containing '%s' not found in: %v", expectedError, result.Errors)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Verify original endpoint is not modified
|
||||
if tt.endpoint.URL != ((&Endpoint{URL: tt.endpoint.URL, Body: tt.endpoint.Body, Headers: tt.endpoint.Headers}).URL) {
|
||||
t.Error("Original endpoint was modified")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/heartbeat"
|
||||
"github.com/TwiN/gatus/v5/config/key"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
)
|
||||
|
||||
@@ -82,7 +83,7 @@ func (externalEndpoint *ExternalEndpoint) DisplayName() string {
|
||||
|
||||
// Key returns the unique key for the Endpoint
|
||||
func (externalEndpoint *ExternalEndpoint) Key() string {
|
||||
return ConvertGroupAndEndpointNameToKey(externalEndpoint.Group, externalEndpoint.Name)
|
||||
return key.ConvertGroupAndNameToKey(externalEndpoint.Group, externalEndpoint.Name)
|
||||
}
|
||||
|
||||
// ToEndpoint converts the ExternalEndpoint to an Endpoint
|
||||
|
||||
@@ -2,24 +2,379 @@ package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/heartbeat"
|
||||
"github.com/TwiN/gatus/v5/config/maintenance"
|
||||
)
|
||||
|
||||
func TestExternalEndpoint_ToEndpoint(t *testing.T) {
|
||||
externalEndpoint := &ExternalEndpoint{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
func TestExternalEndpoint_ValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *ExternalEndpoint
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "valid-external-endpoint",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "valid-external-endpoint-with-heartbeat",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "valid-token",
|
||||
Heartbeat: heartbeat.Config{
|
||||
Interval: 30 * time.Second,
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "missing-token",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
},
|
||||
wantErr: ErrExternalEndpointWithNoToken,
|
||||
},
|
||||
{
|
||||
name: "empty-token",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "",
|
||||
},
|
||||
wantErr: ErrExternalEndpointWithNoToken,
|
||||
},
|
||||
{
|
||||
name: "heartbeat-interval-too-low",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "valid-token",
|
||||
Heartbeat: heartbeat.Config{
|
||||
Interval: 5 * time.Second, // Less than 10 seconds
|
||||
},
|
||||
},
|
||||
wantErr: ErrExternalEndpointHeartbeatIntervalTooLow,
|
||||
},
|
||||
{
|
||||
name: "heartbeat-interval-exactly-10-seconds",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "valid-token",
|
||||
Heartbeat: heartbeat.Config{
|
||||
Interval: 10 * time.Second,
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "heartbeat-interval-zero-is-allowed",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "valid-token",
|
||||
Heartbeat: heartbeat.Config{
|
||||
Interval: 0, // Zero means no heartbeat monitoring
|
||||
},
|
||||
},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "missing-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Group: "test-group",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: ErrEndpointWithNoName,
|
||||
},
|
||||
}
|
||||
convertedEndpoint := externalEndpoint.ToEndpoint()
|
||||
if externalEndpoint.Name != convertedEndpoint.Name {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Name, convertedEndpoint.Name)
|
||||
}
|
||||
if externalEndpoint.Group != convertedEndpoint.Group {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Group, convertedEndpoint.Group)
|
||||
}
|
||||
if externalEndpoint.Key() != convertedEndpoint.Key() {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.Key(), convertedEndpoint.Key())
|
||||
}
|
||||
if externalEndpoint.DisplayName() != convertedEndpoint.DisplayName() {
|
||||
t.Errorf("expected %s, got %s", externalEndpoint.DisplayName(), convertedEndpoint.DisplayName())
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.endpoint.ValidateAndSetDefaults()
|
||||
if tt.wantErr != nil {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error %v, but got none", tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.wantErr.Error() {
|
||||
t.Errorf("Expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Expected no error, but got %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_IsEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled *bool
|
||||
expected bool
|
||||
}{
|
||||
{
|
||||
name: "nil-enabled-defaults-to-true",
|
||||
enabled: nil,
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "explicitly-enabled",
|
||||
enabled: boolPtr(true),
|
||||
expected: true,
|
||||
},
|
||||
{
|
||||
name: "explicitly-disabled",
|
||||
enabled: boolPtr(false),
|
||||
expected: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
endpoint := &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "test-token",
|
||||
Enabled: tt.enabled,
|
||||
}
|
||||
result := endpoint.IsEnabled()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_DisplayName(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *ExternalEndpoint
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "with-group",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
},
|
||||
expected: "test-group/test-endpoint",
|
||||
},
|
||||
{
|
||||
name: "without-group",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "",
|
||||
},
|
||||
expected: "test-endpoint",
|
||||
},
|
||||
{
|
||||
name: "empty-group-string",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "api-health",
|
||||
Group: "",
|
||||
},
|
||||
expected: "api-health",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.endpoint.DisplayName()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_Key(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *ExternalEndpoint
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "with-group",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
},
|
||||
expected: "test-group_test-endpoint",
|
||||
},
|
||||
{
|
||||
name: "without-group",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Group: "",
|
||||
},
|
||||
expected: "_test-endpoint",
|
||||
},
|
||||
{
|
||||
name: "special-characters-in-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test endpoint with spaces",
|
||||
Group: "test-group",
|
||||
},
|
||||
expected: "test-group_test-endpoint-with-spaces",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.endpoint.Key()
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %q, got %q", tt.expected, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_ToEndpoint(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
externalEndpoint *ExternalEndpoint
|
||||
}{
|
||||
{
|
||||
name: "complete-external-endpoint",
|
||||
externalEndpoint: &ExternalEndpoint{
|
||||
Enabled: boolPtr(true),
|
||||
Name: "test-endpoint",
|
||||
Group: "test-group",
|
||||
Token: "test-token",
|
||||
Alerts: []*alert.Alert{
|
||||
{
|
||||
Type: alert.TypeSlack,
|
||||
},
|
||||
},
|
||||
MaintenanceWindows: []*maintenance.Config{
|
||||
{
|
||||
Start: "02:00",
|
||||
Duration: time.Hour,
|
||||
},
|
||||
},
|
||||
NumberOfFailuresInARow: 3,
|
||||
NumberOfSuccessesInARow: 5,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "minimal-external-endpoint",
|
||||
externalEndpoint: &ExternalEndpoint{
|
||||
Name: "minimal-endpoint",
|
||||
Token: "minimal-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "disabled-external-endpoint",
|
||||
externalEndpoint: &ExternalEndpoint{
|
||||
Enabled: boolPtr(false),
|
||||
Name: "disabled-endpoint",
|
||||
Token: "disabled-token",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "original-test-case",
|
||||
externalEndpoint: &ExternalEndpoint{
|
||||
Name: "name",
|
||||
Group: "group",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := tt.externalEndpoint.ToEndpoint()
|
||||
// Verify all fields are correctly copied
|
||||
if result.Enabled != tt.externalEndpoint.Enabled {
|
||||
t.Errorf("Expected Enabled=%v, got %v", tt.externalEndpoint.Enabled, result.Enabled)
|
||||
}
|
||||
if result.Name != tt.externalEndpoint.Name {
|
||||
t.Errorf("Expected Name=%q, got %q", tt.externalEndpoint.Name, result.Name)
|
||||
}
|
||||
if result.Group != tt.externalEndpoint.Group {
|
||||
t.Errorf("Expected Group=%q, got %q", tt.externalEndpoint.Group, result.Group)
|
||||
}
|
||||
if len(result.Alerts) != len(tt.externalEndpoint.Alerts) {
|
||||
t.Errorf("Expected %d alerts, got %d", len(tt.externalEndpoint.Alerts), len(result.Alerts))
|
||||
}
|
||||
if result.NumberOfFailuresInARow != tt.externalEndpoint.NumberOfFailuresInARow {
|
||||
t.Errorf("Expected NumberOfFailuresInARow=%d, got %d", tt.externalEndpoint.NumberOfFailuresInARow, result.NumberOfFailuresInARow)
|
||||
}
|
||||
if result.NumberOfSuccessesInARow != tt.externalEndpoint.NumberOfSuccessesInARow {
|
||||
t.Errorf("Expected NumberOfSuccessesInARow=%d, got %d", tt.externalEndpoint.NumberOfSuccessesInARow, result.NumberOfSuccessesInARow)
|
||||
}
|
||||
// Original test assertions
|
||||
if tt.externalEndpoint.Key() != result.Key() {
|
||||
t.Errorf("expected %s, got %s", tt.externalEndpoint.Key(), result.Key())
|
||||
}
|
||||
if tt.externalEndpoint.DisplayName() != result.DisplayName() {
|
||||
t.Errorf("expected %s, got %s", tt.externalEndpoint.DisplayName(), result.DisplayName())
|
||||
}
|
||||
// Verify it's a proper Endpoint type
|
||||
if result == nil {
|
||||
t.Error("ToEndpoint() returned nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalEndpoint_ValidationEdgeCases(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpoint *ExternalEndpoint
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "very-long-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "this-is-a-very-long-endpoint-name-that-might-cause-issues-in-some-systems-but-should-be-handled-gracefully",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "special-characters-in-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint@#$%^&*()",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "unicode-characters-in-name",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "测试端点",
|
||||
Token: "valid-token",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "very-long-token",
|
||||
endpoint: &ExternalEndpoint{
|
||||
Name: "test-endpoint",
|
||||
Token: "very-long-token-that-should-still-be-valid-even-though-it-is-extremely-long-and-might-not-be-practical-in-real-world-scenarios",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.endpoint.ValidateAndSetDefaults()
|
||||
if tt.wantErr && err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
if !tt.wantErr && err != nil {
|
||||
t.Errorf("Expected no error but got: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create bool pointers
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkConvertGroupAndEndpointNameToKey(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
ConvertGroupAndEndpointNameToKey("group", "name")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/jsonpath"
|
||||
)
|
||||
|
||||
// Placeholders
|
||||
const (
|
||||
// StatusPlaceholder is a placeholder for a HTTP status.
|
||||
//
|
||||
// Values that could replace the placeholder: 200, 404, 500, ...
|
||||
StatusPlaceholder = "[STATUS]"
|
||||
|
||||
// IPPlaceholder is a placeholder for an IP.
|
||||
//
|
||||
// Values that could replace the placeholder: 127.0.0.1, 10.0.0.1, ...
|
||||
IPPlaceholder = "[IP]"
|
||||
|
||||
// DNSRCodePlaceholder is a placeholder for DNS_RCODE
|
||||
//
|
||||
// Values that could replace the placeholder: NOERROR, FORMERR, SERVFAIL, NXDOMAIN, NOTIMP, REFUSED
|
||||
DNSRCodePlaceholder = "[DNS_RCODE]"
|
||||
|
||||
// ResponseTimePlaceholder is a placeholder for the request response time, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 1, 500, 1000, ...
|
||||
ResponseTimePlaceholder = "[RESPONSE_TIME]"
|
||||
|
||||
// BodyPlaceholder is a placeholder for the Body of the response
|
||||
//
|
||||
// Values that could replace the placeholder: {}, {"data":{"name":"john"}}, ...
|
||||
BodyPlaceholder = "[BODY]"
|
||||
|
||||
// ConnectedPlaceholder is a placeholder for whether a connection was successfully established.
|
||||
//
|
||||
// Values that could replace the placeholder: true, false
|
||||
ConnectedPlaceholder = "[CONNECTED]"
|
||||
|
||||
// CertificateExpirationPlaceholder is a placeholder for the duration before certificate expiration, in milliseconds.
|
||||
//
|
||||
// Values that could replace the placeholder: 4461677039 (~52 days)
|
||||
CertificateExpirationPlaceholder = "[CERTIFICATE_EXPIRATION]"
|
||||
|
||||
// DomainExpirationPlaceholder is a placeholder for the duration before the domain expires, in milliseconds.
|
||||
DomainExpirationPlaceholder = "[DOMAIN_EXPIRATION]"
|
||||
|
||||
// ContextPlaceholder is a placeholder for suite context values
|
||||
// Usage: [CONTEXT].path.to.value
|
||||
ContextPlaceholder = "[CONTEXT]"
|
||||
)
|
||||
|
||||
// Functions
|
||||
const (
|
||||
// LengthFunctionPrefix is the prefix for the length function
|
||||
//
|
||||
// Usage: len([BODY].articles) == 10, len([BODY].name) > 5
|
||||
LengthFunctionPrefix = "len("
|
||||
|
||||
// HasFunctionPrefix is the prefix for the has function
|
||||
//
|
||||
// Usage: has([BODY].errors) == true
|
||||
HasFunctionPrefix = "has("
|
||||
|
||||
// PatternFunctionPrefix is the prefix for the pattern function
|
||||
//
|
||||
// Usage: [IP] == pat(192.168.*.*)
|
||||
PatternFunctionPrefix = "pat("
|
||||
|
||||
// AnyFunctionPrefix is the prefix for the any function
|
||||
//
|
||||
// Usage: [IP] == any(1.1.1.1, 1.0.0.1)
|
||||
AnyFunctionPrefix = "any("
|
||||
|
||||
// FunctionSuffix is the suffix for all functions
|
||||
FunctionSuffix = ")"
|
||||
)
|
||||
|
||||
// Other constants
|
||||
const (
|
||||
// InvalidConditionElementSuffix is the suffix that will be appended to an invalid condition
|
||||
InvalidConditionElementSuffix = "(INVALID)"
|
||||
)
|
||||
|
||||
// functionType represents the type of function wrapper
|
||||
type functionType int
|
||||
|
||||
const (
|
||||
// Note that not all functions are handled here. Only len() and has() directly impact the handler
|
||||
// e.g. "len([BODY].name) > 0" vs pat() or any(), which would be used like "[BODY].name == pat(john*)"
|
||||
|
||||
noFunction functionType = iota
|
||||
functionLen
|
||||
functionHas
|
||||
)
|
||||
|
||||
// ResolvePlaceholder resolves all types of placeholders to their string values.
|
||||
//
|
||||
// Supported placeholders:
|
||||
// - [STATUS]: HTTP status code (e.g., "200", "404")
|
||||
// - [IP]: IP address from the response (e.g., "127.0.0.1")
|
||||
// - [RESPONSE_TIME]: Response time in milliseconds (e.g., "250")
|
||||
// - [DNS_RCODE]: DNS response code (e.g., "NOERROR", "NXDOMAIN")
|
||||
// - [CONNECTED]: Connection status (e.g., "true", "false")
|
||||
// - [CERTIFICATE_EXPIRATION]: Certificate expiration time in milliseconds
|
||||
// - [DOMAIN_EXPIRATION]: Domain expiration time in milliseconds
|
||||
// - [BODY]: Full response body
|
||||
// - [BODY].path: JSONPath expression on response body (e.g., [BODY].status, [BODY].data[0].name)
|
||||
// - [CONTEXT].path: Suite context values (e.g., [CONTEXT].user_id, [CONTEXT].session_token)
|
||||
//
|
||||
// Function wrappers:
|
||||
// - len(placeholder): Returns the length of the resolved value
|
||||
// - has(placeholder): Returns "true" if the placeholder exists and is non-empty, "false" otherwise
|
||||
//
|
||||
// Examples:
|
||||
// - ResolvePlaceholder("[STATUS]", result, nil) → "200"
|
||||
// - ResolvePlaceholder("len([BODY].items)", result, nil) → "5" (for JSON array with 5 items)
|
||||
// - ResolvePlaceholder("has([CONTEXT].user_id)", result, ctx) → "true" (if context has user_id)
|
||||
// - ResolvePlaceholder("[BODY].user.name", result, nil) → "john" (for {"user":{"name":"john"}})
|
||||
//
|
||||
// Case-insensitive: All placeholder names are handled case-insensitively, but paths preserve original case.
|
||||
func ResolvePlaceholder(placeholder string, result *Result, ctx *gontext.Gontext) (string, error) {
|
||||
placeholder = strings.TrimSpace(placeholder)
|
||||
originalPlaceholder := placeholder
|
||||
|
||||
// Extract function wrapper if present
|
||||
fn, innerPlaceholder := extractFunctionWrapper(placeholder)
|
||||
placeholder = innerPlaceholder
|
||||
|
||||
// Handle CONTEXT placeholders
|
||||
uppercasePlaceholder := strings.ToUpper(placeholder)
|
||||
if strings.HasPrefix(uppercasePlaceholder, ContextPlaceholder) && ctx != nil {
|
||||
return resolveContextPlaceholder(placeholder, fn, originalPlaceholder, ctx)
|
||||
}
|
||||
|
||||
// Handle basic placeholders (try uppercase first for backward compatibility)
|
||||
switch uppercasePlaceholder {
|
||||
case StatusPlaceholder:
|
||||
return formatWithFunction(strconv.Itoa(result.HTTPStatus), fn), nil
|
||||
case IPPlaceholder:
|
||||
return formatWithFunction(result.IP, fn), nil
|
||||
case ResponseTimePlaceholder:
|
||||
return formatWithFunction(strconv.FormatInt(result.Duration.Milliseconds(), 10), fn), nil
|
||||
case DNSRCodePlaceholder:
|
||||
return formatWithFunction(result.DNSRCode, fn), nil
|
||||
case ConnectedPlaceholder:
|
||||
return formatWithFunction(strconv.FormatBool(result.Connected), fn), nil
|
||||
case CertificateExpirationPlaceholder:
|
||||
return formatWithFunction(strconv.FormatInt(result.CertificateExpiration.Milliseconds(), 10), fn), nil
|
||||
case DomainExpirationPlaceholder:
|
||||
return formatWithFunction(strconv.FormatInt(result.DomainExpiration.Milliseconds(), 10), fn), nil
|
||||
case BodyPlaceholder:
|
||||
body := strings.TrimSpace(string(result.Body))
|
||||
if fn == functionHas {
|
||||
return strconv.FormatBool(len(body) > 0), nil
|
||||
}
|
||||
if fn == functionLen {
|
||||
// For len([BODY]), we need to check if it's JSON and get the actual length
|
||||
// Use jsonpath to evaluate the root element
|
||||
_, resolvedLength, err := jsonpath.Eval("", result.Body)
|
||||
if err == nil {
|
||||
return strconv.Itoa(resolvedLength), nil
|
||||
}
|
||||
// Fall back to string length if not valid JSON
|
||||
return strconv.Itoa(len(body)), nil
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Handle JSONPath expressions on BODY (including array indexing)
|
||||
if strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder+".") || strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder+"[") {
|
||||
return resolveJSONPathPlaceholder(placeholder, fn, originalPlaceholder, result)
|
||||
}
|
||||
|
||||
// Not a recognized placeholder
|
||||
if fn != noFunction {
|
||||
if fn == functionHas {
|
||||
return "false", nil
|
||||
}
|
||||
// For len() with unrecognized placeholder, return with INVALID suffix
|
||||
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
|
||||
}
|
||||
|
||||
// Return the original placeholder if we can't resolve it
|
||||
// This allows for literal string comparisons
|
||||
return originalPlaceholder, nil
|
||||
}
|
||||
|
||||
// extractFunctionWrapper detects and extracts function wrappers (len, has)
|
||||
func extractFunctionWrapper(placeholder string) (functionType, string) {
|
||||
if strings.HasPrefix(placeholder, LengthFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, LengthFunctionPrefix), FunctionSuffix)
|
||||
return functionLen, inner
|
||||
}
|
||||
if strings.HasPrefix(placeholder, HasFunctionPrefix) && strings.HasSuffix(placeholder, FunctionSuffix) {
|
||||
inner := strings.TrimSuffix(strings.TrimPrefix(placeholder, HasFunctionPrefix), FunctionSuffix)
|
||||
return functionHas, inner
|
||||
}
|
||||
return noFunction, placeholder
|
||||
}
|
||||
|
||||
// resolveJSONPathPlaceholder handles [BODY].path and [BODY][index] placeholders
|
||||
func resolveJSONPathPlaceholder(placeholder string, fn functionType, originalPlaceholder string, result *Result) (string, error) {
|
||||
// Extract the path after [BODY] (case insensitive)
|
||||
uppercasePlaceholder := strings.ToUpper(placeholder)
|
||||
path := ""
|
||||
if strings.HasPrefix(uppercasePlaceholder, BodyPlaceholder) {
|
||||
path = placeholder[len(BodyPlaceholder):]
|
||||
} else {
|
||||
path = strings.TrimPrefix(placeholder, BodyPlaceholder)
|
||||
}
|
||||
// Remove leading dot if present
|
||||
path = strings.TrimPrefix(path, ".")
|
||||
resolvedValue, resolvedLength, err := jsonpath.Eval(path, result.Body)
|
||||
if fn == functionHas {
|
||||
return strconv.FormatBool(err == nil), nil
|
||||
}
|
||||
if err != nil {
|
||||
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
|
||||
}
|
||||
if fn == functionLen {
|
||||
return strconv.Itoa(resolvedLength), nil
|
||||
}
|
||||
return resolvedValue, nil
|
||||
}
|
||||
|
||||
// resolveContextPlaceholder handles [CONTEXT] placeholder resolution
|
||||
func resolveContextPlaceholder(placeholder string, fn functionType, originalPlaceholder string, ctx *gontext.Gontext) (string, error) {
|
||||
contextPath := strings.TrimPrefix(placeholder, ContextPlaceholder)
|
||||
contextPath = strings.TrimPrefix(contextPath, ".")
|
||||
if contextPath == "" {
|
||||
if fn == functionHas {
|
||||
return "false", nil
|
||||
}
|
||||
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
|
||||
}
|
||||
value, err := ctx.Get(contextPath)
|
||||
if fn == functionHas {
|
||||
return strconv.FormatBool(err == nil), nil
|
||||
}
|
||||
if err != nil {
|
||||
return originalPlaceholder + " " + InvalidConditionElementSuffix, nil
|
||||
}
|
||||
if fn == functionLen {
|
||||
switch v := value.(type) {
|
||||
case string:
|
||||
return strconv.Itoa(len(v)), nil
|
||||
case []interface{}:
|
||||
return strconv.Itoa(len(v)), nil
|
||||
case map[string]interface{}:
|
||||
return strconv.Itoa(len(v)), nil
|
||||
default:
|
||||
return strconv.Itoa(len(fmt.Sprintf("%v", v))), nil
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%v", value), nil
|
||||
}
|
||||
|
||||
// formatWithFunction applies len/has functions to any value
|
||||
func formatWithFunction(value string, fn functionType) string {
|
||||
switch fn {
|
||||
case functionHas:
|
||||
return strconv.FormatBool(value != "")
|
||||
case functionLen:
|
||||
return strconv.Itoa(len(value))
|
||||
default:
|
||||
return value
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package endpoint
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
)
|
||||
|
||||
func TestResolvePlaceholder(t *testing.T) {
|
||||
result := &Result{
|
||||
HTTPStatus: 200,
|
||||
IP: "127.0.0.1",
|
||||
Duration: 250 * time.Millisecond,
|
||||
DNSRCode: "NOERROR",
|
||||
Connected: true,
|
||||
CertificateExpiration: 30 * 24 * time.Hour,
|
||||
DomainExpiration: 365 * 24 * time.Hour,
|
||||
Body: []byte(`{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`),
|
||||
}
|
||||
|
||||
ctx := gontext.New(map[string]interface{}{
|
||||
"user_id": "abc123",
|
||||
"session_token": "xyz789",
|
||||
"array_data": []interface{}{"a", "b", "c"},
|
||||
"nested": map[string]interface{}{
|
||||
"value": "test",
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
placeholder string
|
||||
expected string
|
||||
}{
|
||||
// Basic placeholders
|
||||
{"status", "[STATUS]", "200"},
|
||||
{"ip", "[IP]", "127.0.0.1"},
|
||||
{"response-time", "[RESPONSE_TIME]", "250"},
|
||||
{"dns-rcode", "[DNS_RCODE]", "NOERROR"},
|
||||
{"connected", "[CONNECTED]", "true"},
|
||||
{"certificate-expiration", "[CERTIFICATE_EXPIRATION]", "2592000000"},
|
||||
{"domain-expiration", "[DOMAIN_EXPIRATION]", "31536000000"},
|
||||
{"body", "[BODY]", `{"status":"success","items":[1,2,3],"user":{"name":"john","id":123}}`},
|
||||
|
||||
// Case insensitive placeholders
|
||||
{"status-lowercase", "[status]", "200"},
|
||||
{"ip-mixed-case", "[Ip]", "127.0.0.1"},
|
||||
|
||||
// Function wrappers on basic placeholders
|
||||
{"len-status", "len([STATUS])", "3"},
|
||||
{"len-ip", "len([IP])", "9"},
|
||||
{"has-status", "has([STATUS])", "true"},
|
||||
{"has-empty", "has()", "false"},
|
||||
|
||||
// JSONPath expressions
|
||||
{"body-status", "[BODY].status", "success"},
|
||||
{"body-user-name", "[BODY].user.name", "john"},
|
||||
{"body-user-id", "[BODY].user.id", "123"},
|
||||
{"len-body-items", "len([BODY].items)", "3"},
|
||||
{"body-array-index", "[BODY].items[0]", "1"},
|
||||
{"has-body-status", "has([BODY].status)", "true"},
|
||||
{"has-body-missing", "has([BODY].missing)", "false"},
|
||||
|
||||
// Context placeholders
|
||||
{"context-user-id", "[CONTEXT].user_id", "abc123"},
|
||||
{"context-session-token", "[CONTEXT].session_token", "xyz789"},
|
||||
{"context-nested", "[CONTEXT].nested.value", "test"},
|
||||
{"len-context-array", "len([CONTEXT].array_data)", "3"},
|
||||
{"has-context-user-id", "has([CONTEXT].user_id)", "true"},
|
||||
{"has-context-missing", "has([CONTEXT].missing)", "false"},
|
||||
|
||||
// Invalid placeholders
|
||||
{"unknown-placeholder", "[UNKNOWN]", "[UNKNOWN]"},
|
||||
{"len-unknown", "len([UNKNOWN])", "len([UNKNOWN]) (INVALID)"},
|
||||
{"has-unknown", "has([UNKNOWN])", "false"},
|
||||
{"invalid-jsonpath", "[BODY].invalid.path", "[BODY].invalid.path (INVALID)"},
|
||||
|
||||
// Literal strings
|
||||
{"literal-string", "literal", "literal"},
|
||||
{"number-string", "123", "123"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actual, err := ResolvePlaceholder(test.placeholder, result, ctx)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if actual != test.expected {
|
||||
t.Errorf("expected '%s', got '%s'", test.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolvePlaceholderWithoutContext(t *testing.T) {
|
||||
result := &Result{
|
||||
HTTPStatus: 404,
|
||||
Body: []byte(`{"error":"not found"}`),
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
placeholder string
|
||||
expected string
|
||||
}{
|
||||
{"status-without-context", "[STATUS]", "404"},
|
||||
{"body-without-context", "[BODY].error", "not found"},
|
||||
{"context-without-context", "[CONTEXT].user_id", "[CONTEXT].user_id"},
|
||||
{"has-context-without-context", "has([CONTEXT].user_id)", "false"},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
actual, err := ResolvePlaceholder(test.placeholder, result, nil)
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
if actual != test.expected {
|
||||
t.Errorf("expected '%s', got '%s'", test.expected, actual)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Result of the evaluation of a Endpoint
|
||||
// Result of the evaluation of an Endpoint
|
||||
type Result struct {
|
||||
// HTTPStatus is the HTTP response status code
|
||||
HTTPStatus int `json:"status,omitempty"`
|
||||
@@ -54,6 +54,13 @@ type Result struct {
|
||||
// Below is used only for the UI and is not persisted in the storage //
|
||||
///////////////////////////////////////////////////////////////////////
|
||||
port string `yaml:"-"` // used for endpoints[].ui.hide-port
|
||||
|
||||
///////////////////////////////////
|
||||
// BELOW IS ONLY USED FOR SUITES //
|
||||
///////////////////////////////////
|
||||
// Name of the endpoint (ONLY USED FOR SUITES)
|
||||
// Group is not needed because it's inherited from the suite
|
||||
Name string `json:"name,omitempty"`
|
||||
}
|
||||
|
||||
// AddError adds an error to the result's list of errors.
|
||||
@@ -65,5 +72,5 @@ func (r *Result) AddError(error string) {
|
||||
return
|
||||
}
|
||||
}
|
||||
r.Errors = append(r.Errors, error)
|
||||
r.Errors = append(r.Errors, error+"")
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
package endpoint
|
||||
|
||||
import "github.com/TwiN/gatus/v5/config/key"
|
||||
|
||||
// Status contains the evaluation Results of an Endpoint
|
||||
// This is essentially a DTO
|
||||
type Status struct {
|
||||
// Name of the endpoint
|
||||
Name string `json:"name,omitempty"`
|
||||
@@ -30,7 +33,7 @@ func NewStatus(group, name string) *Status {
|
||||
return &Status{
|
||||
Name: name,
|
||||
Group: group,
|
||||
Key: ConvertGroupAndEndpointNameToKey(group, name),
|
||||
Key: key.ConvertGroupAndNameToKey(group, name),
|
||||
Results: make([]*Result, 0),
|
||||
Events: make([]*Event, 0),
|
||||
Uptime: NewUptime(),
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
package gontext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrGontextPathNotFound is returned when a gontext path doesn't exist
|
||||
ErrGontextPathNotFound = errors.New("gontext path not found")
|
||||
)
|
||||
|
||||
// Gontext holds values that can be shared between endpoints in a suite
|
||||
type Gontext struct {
|
||||
mu sync.RWMutex
|
||||
values map[string]interface{}
|
||||
}
|
||||
|
||||
// New creates a new gontext with initial values
|
||||
func New(initial map[string]interface{}) *Gontext {
|
||||
if initial == nil {
|
||||
initial = make(map[string]interface{})
|
||||
}
|
||||
// Create a deep copy to avoid external modifications
|
||||
values := make(map[string]interface{})
|
||||
for k, v := range initial {
|
||||
values[k] = deepCopyValue(v)
|
||||
}
|
||||
return &Gontext{
|
||||
values: values,
|
||||
}
|
||||
}
|
||||
|
||||
// Get retrieves a value from the gontext using dot notation
|
||||
func (g *Gontext) Get(path string) (interface{}, error) {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
parts := strings.Split(path, ".")
|
||||
current := interface{}(g.values)
|
||||
for _, part := range parts {
|
||||
switch v := current.(type) {
|
||||
case map[string]interface{}:
|
||||
val, exists := v[part]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("%w: %s", ErrGontextPathNotFound, path)
|
||||
}
|
||||
current = val
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: %s", ErrGontextPathNotFound, path)
|
||||
}
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
// Set stores a value in the gontext using dot notation
|
||||
func (g *Gontext) Set(path string, value interface{}) error {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
parts := strings.Split(path, ".")
|
||||
if len(parts) == 0 {
|
||||
return errors.New("empty path")
|
||||
}
|
||||
// Navigate to the parent of the target
|
||||
current := g.values
|
||||
for i := 0; i < len(parts)-1; i++ {
|
||||
part := parts[i]
|
||||
if next, exists := current[part]; exists {
|
||||
if nextMap, ok := next.(map[string]interface{}); ok {
|
||||
current = nextMap
|
||||
} else {
|
||||
// Path exists but is not a map, create a new map
|
||||
newMap := make(map[string]interface{})
|
||||
current[part] = newMap
|
||||
current = newMap
|
||||
}
|
||||
} else {
|
||||
// Create intermediate maps
|
||||
newMap := make(map[string]interface{})
|
||||
current[part] = newMap
|
||||
current = newMap
|
||||
}
|
||||
}
|
||||
// Set the final value
|
||||
current[parts[len(parts)-1]] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAll returns a copy of all gontext values
|
||||
func (g *Gontext) GetAll() map[string]interface{} {
|
||||
g.mu.RLock()
|
||||
defer g.mu.RUnlock()
|
||||
|
||||
result := make(map[string]interface{})
|
||||
for k, v := range g.values {
|
||||
result[k] = deepCopyValue(v)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// deepCopyValue creates a deep copy of a value
|
||||
func deepCopyValue(v interface{}) interface{} {
|
||||
switch val := v.(type) {
|
||||
case map[string]interface{}:
|
||||
newMap := make(map[string]interface{})
|
||||
for k, v := range val {
|
||||
newMap[k] = deepCopyValue(v)
|
||||
}
|
||||
return newMap
|
||||
case []interface{}:
|
||||
newSlice := make([]interface{}, len(val))
|
||||
for i, v := range val {
|
||||
newSlice[i] = deepCopyValue(v)
|
||||
}
|
||||
return newSlice
|
||||
default:
|
||||
// For primitive types, return as-is (they're passed by value anyway)
|
||||
return val
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,448 @@
|
||||
package gontext
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
initial map[string]interface{}
|
||||
expected map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "nil-input",
|
||||
initial: nil,
|
||||
expected: make(map[string]interface{}),
|
||||
},
|
||||
{
|
||||
name: "empty-input",
|
||||
initial: make(map[string]interface{}),
|
||||
expected: make(map[string]interface{}),
|
||||
},
|
||||
{
|
||||
name: "simple-values",
|
||||
initial: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested-values",
|
||||
initial: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": 123,
|
||||
"name": "John Doe",
|
||||
},
|
||||
},
|
||||
expected: map[string]interface{}{
|
||||
"user": map[string]interface{}{
|
||||
"id": 123,
|
||||
"name": "John Doe",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := New(tt.initial)
|
||||
if ctx == nil {
|
||||
t.Error("Expected non-nil gontext")
|
||||
}
|
||||
if ctx.values == nil {
|
||||
t.Error("Expected non-nil values map")
|
||||
}
|
||||
|
||||
// Verify deep copy by modifying original
|
||||
if tt.initial != nil {
|
||||
tt.initial["modified"] = "should not appear"
|
||||
if _, exists := ctx.values["modified"]; exists {
|
||||
t.Error("Deep copy failed - original map modification affected gontext")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_Get(t *testing.T) {
|
||||
ctx := New(map[string]interface{}{
|
||||
"simple": "value",
|
||||
"number": 42,
|
||||
"boolean": true,
|
||||
"nested": map[string]interface{}{
|
||||
"level1": map[string]interface{}{
|
||||
"level2": "deep_value",
|
||||
},
|
||||
},
|
||||
"user": map[string]interface{}{
|
||||
"id": 123,
|
||||
"name": "John",
|
||||
"profile": map[string]interface{}{
|
||||
"email": "john@example.com",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
expected interface{}
|
||||
shouldError bool
|
||||
errorType error
|
||||
}{
|
||||
{
|
||||
name: "simple-value",
|
||||
path: "simple",
|
||||
expected: "value",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "number-value",
|
||||
path: "number",
|
||||
expected: 42,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "boolean-value",
|
||||
path: "boolean",
|
||||
expected: true,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "nested-value",
|
||||
path: "nested.level1.level2",
|
||||
expected: "deep_value",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "user-id",
|
||||
path: "user.id",
|
||||
expected: 123,
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "deep-nested-value",
|
||||
path: "user.profile.email",
|
||||
expected: "john@example.com",
|
||||
shouldError: false,
|
||||
},
|
||||
{
|
||||
name: "non-existent-key",
|
||||
path: "nonexistent",
|
||||
expected: nil,
|
||||
shouldError: true,
|
||||
errorType: ErrGontextPathNotFound,
|
||||
},
|
||||
{
|
||||
name: "non-existent-nested-key",
|
||||
path: "user.nonexistent",
|
||||
expected: nil,
|
||||
shouldError: true,
|
||||
errorType: ErrGontextPathNotFound,
|
||||
},
|
||||
{
|
||||
name: "invalid-nested-path",
|
||||
path: "simple.invalid",
|
||||
expected: nil,
|
||||
shouldError: true,
|
||||
errorType: ErrGontextPathNotFound,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := ctx.Get(tt.path)
|
||||
|
||||
if tt.shouldError {
|
||||
if err == nil {
|
||||
t.Errorf("Expected error but got none")
|
||||
}
|
||||
if tt.errorType != nil && !errors.Is(err, tt.errorType) {
|
||||
t.Errorf("Expected error type %v, got %v", tt.errorType, err)
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if result != tt.expected {
|
||||
t.Errorf("Expected %v, got %v", tt.expected, result)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_Set(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
value interface{}
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "simple-set",
|
||||
path: "key",
|
||||
value: "value",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "nested-set",
|
||||
path: "user.name",
|
||||
value: "John Doe",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "deep-nested-set",
|
||||
path: "user.profile.email",
|
||||
value: "john@example.com",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "override-primitive-with-nested",
|
||||
path: "existing.new",
|
||||
value: "nested_value",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty-path",
|
||||
path: "",
|
||||
value: "value",
|
||||
wantErr: false, // Actually, empty string creates a single part [""], which is valid
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
ctx := New(map[string]interface{}{
|
||||
"existing": "primitive",
|
||||
})
|
||||
|
||||
err := ctx.Set(tt.path, tt.value)
|
||||
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Error("Expected error but got none")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Verify the value was set correctly
|
||||
result, getErr := ctx.Get(tt.path)
|
||||
if getErr != nil {
|
||||
t.Errorf("Error retrieving set value: %v", getErr)
|
||||
return
|
||||
}
|
||||
|
||||
if result != tt.value {
|
||||
t.Errorf("Expected %v, got %v", tt.value, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_SetOverrideBehavior(t *testing.T) {
|
||||
ctx := New(map[string]interface{}{
|
||||
"primitive": "value",
|
||||
"nested": map[string]interface{}{
|
||||
"key": "existing",
|
||||
},
|
||||
})
|
||||
|
||||
// Test overriding primitive with nested structure
|
||||
err := ctx.Set("primitive.new", "nested_value")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
// Verify the primitive was replaced with a nested structure
|
||||
result, err := ctx.Get("primitive.new")
|
||||
if err != nil {
|
||||
t.Errorf("Error getting nested value: %v", err)
|
||||
}
|
||||
if result != "nested_value" {
|
||||
t.Errorf("Expected 'nested_value', got %v", result)
|
||||
}
|
||||
|
||||
// Test overriding existing nested value
|
||||
err = ctx.Set("nested.key", "modified")
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
|
||||
result, err = ctx.Get("nested.key")
|
||||
if err != nil {
|
||||
t.Errorf("Error getting modified value: %v", err)
|
||||
}
|
||||
if result != "modified" {
|
||||
t.Errorf("Expected 'modified', got %v", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_GetAll(t *testing.T) {
|
||||
initial := map[string]interface{}{
|
||||
"key1": "value1",
|
||||
"key2": 42,
|
||||
"nested": map[string]interface{}{
|
||||
"inner": "value",
|
||||
},
|
||||
}
|
||||
|
||||
ctx := New(initial)
|
||||
|
||||
// Add another value after creation
|
||||
ctx.Set("key3", "value3")
|
||||
|
||||
result := ctx.GetAll()
|
||||
|
||||
// Verify all values are present
|
||||
if result["key1"] != "value1" {
|
||||
t.Errorf("Expected key1=value1, got %v", result["key1"])
|
||||
}
|
||||
if result["key2"] != 42 {
|
||||
t.Errorf("Expected key2=42, got %v", result["key2"])
|
||||
}
|
||||
if result["key3"] != "value3" {
|
||||
t.Errorf("Expected key3=value3, got %v", result["key3"])
|
||||
}
|
||||
|
||||
// Verify nested values
|
||||
nested, ok := result["nested"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Expected nested to be map[string]interface{}")
|
||||
} else if nested["inner"] != "value" {
|
||||
t.Errorf("Expected nested.inner=value, got %v", nested["inner"])
|
||||
}
|
||||
|
||||
// Verify deep copy - modifying returned map shouldn't affect gontext
|
||||
result["key1"] = "modified"
|
||||
original, _ := ctx.Get("key1")
|
||||
if original != "value1" {
|
||||
t.Error("GetAll did not return a deep copy - modification affected original")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGontext_ConcurrentAccess(t *testing.T) {
|
||||
ctx := New(map[string]interface{}{
|
||||
"counter": 0,
|
||||
})
|
||||
|
||||
done := make(chan bool, 10)
|
||||
|
||||
// Start 5 goroutines that read values
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
_, err := ctx.Get("counter")
|
||||
if err != nil {
|
||||
t.Errorf("Reader %d error: %v", id, err)
|
||||
}
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Start 5 goroutines that write values
|
||||
for i := 0; i < 5; i++ {
|
||||
go func(id int) {
|
||||
for j := 0; j < 100; j++ {
|
||||
err := ctx.Set("counter", id*1000+j)
|
||||
if err != nil {
|
||||
t.Errorf("Writer %d error: %v", id, err)
|
||||
}
|
||||
}
|
||||
done <- true
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all goroutines to complete
|
||||
for i := 0; i < 10; i++ {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeepCopyValue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input interface{}
|
||||
}{
|
||||
{
|
||||
name: "primitive-string",
|
||||
input: "test",
|
||||
},
|
||||
{
|
||||
name: "primitive-int",
|
||||
input: 42,
|
||||
},
|
||||
{
|
||||
name: "primitive-bool",
|
||||
input: true,
|
||||
},
|
||||
{
|
||||
name: "simple-map",
|
||||
input: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nested-map",
|
||||
input: map[string]interface{}{
|
||||
"nested": map[string]interface{}{
|
||||
"deep": "value",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "simple-slice",
|
||||
input: []interface{}{"a", "b", "c"},
|
||||
},
|
||||
{
|
||||
name: "mixed-slice",
|
||||
input: []interface{}{
|
||||
"string",
|
||||
42,
|
||||
map[string]interface{}{"nested": "value"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := deepCopyValue(tt.input)
|
||||
|
||||
// For maps and slices, verify it's a different object
|
||||
switch v := tt.input.(type) {
|
||||
case map[string]interface{}:
|
||||
resultMap, ok := result.(map[string]interface{})
|
||||
if !ok {
|
||||
t.Error("Deep copy didn't preserve map type")
|
||||
return
|
||||
}
|
||||
// Modify original to ensure independence
|
||||
v["modified"] = "test"
|
||||
if _, exists := resultMap["modified"]; exists {
|
||||
t.Error("Deep copy failed - maps are not independent")
|
||||
}
|
||||
case []interface{}:
|
||||
resultSlice, ok := result.([]interface{})
|
||||
if !ok {
|
||||
t.Error("Deep copy didn't preserve slice type")
|
||||
return
|
||||
}
|
||||
if len(resultSlice) != len(v) {
|
||||
t.Error("Deep copy didn't preserve slice length")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
package endpoint
|
||||
package key
|
||||
|
||||
import "strings"
|
||||
|
||||
// ConvertGroupAndEndpointNameToKey converts a group and an endpoint to a key
|
||||
func ConvertGroupAndEndpointNameToKey(groupName, endpointName string) string {
|
||||
return sanitize(groupName) + "_" + sanitize(endpointName)
|
||||
// ConvertGroupAndNameToKey converts a group and a name to a key
|
||||
func ConvertGroupAndNameToKey(groupName, name string) string {
|
||||
return sanitize(groupName) + "_" + sanitize(name)
|
||||
}
|
||||
|
||||
func sanitize(s string) string {
|
||||
@@ -16,4 +16,4 @@ func sanitize(s string) string {
|
||||
s = strings.ReplaceAll(s, " ", "-")
|
||||
s = strings.ReplaceAll(s, "#", "-")
|
||||
return s
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package key
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func BenchmarkConvertGroupAndNameToKey(b *testing.B) {
|
||||
for n := 0; n < b.N; n++ {
|
||||
ConvertGroupAndNameToKey("group", "name")
|
||||
}
|
||||
}
|
||||
@@ -1,33 +1,38 @@
|
||||
package endpoint
|
||||
package key
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestConvertGroupAndEndpointNameToKey(t *testing.T) {
|
||||
func TestConvertGroupAndNameToKey(t *testing.T) {
|
||||
type Scenario struct {
|
||||
GroupName string
|
||||
EndpointName string
|
||||
Name string
|
||||
ExpectedOutput string
|
||||
}
|
||||
scenarios := []Scenario{
|
||||
{
|
||||
GroupName: "Core",
|
||||
EndpointName: "Front End",
|
||||
Name: "Front End",
|
||||
ExpectedOutput: "core_front-end",
|
||||
},
|
||||
{
|
||||
GroupName: "Load balancers",
|
||||
EndpointName: "us-west-2",
|
||||
Name: "us-west-2",
|
||||
ExpectedOutput: "load-balancers_us-west-2",
|
||||
},
|
||||
{
|
||||
GroupName: "a/b test",
|
||||
EndpointName: "a",
|
||||
Name: "a",
|
||||
ExpectedOutput: "a-b-test_a",
|
||||
},
|
||||
{
|
||||
GroupName: "",
|
||||
Name: "name",
|
||||
ExpectedOutput: "_name",
|
||||
},
|
||||
}
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.ExpectedOutput, func(t *testing.T) {
|
||||
output := ConvertGroupAndEndpointNameToKey(scenario.GroupName, scenario.EndpointName)
|
||||
output := ConvertGroupAndNameToKey(scenario.GroupName, scenario.Name)
|
||||
if output != scenario.ExpectedOutput {
|
||||
t.Errorf("expected '%s', got '%s'", scenario.ExpectedOutput, output)
|
||||
}
|
||||
@@ -31,17 +31,17 @@ var (
|
||||
//
|
||||
// Uses UTC by default.
|
||||
type Config struct {
|
||||
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
||||
Start string `yaml:"start"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||
Duration time.Duration `yaml:"duration"` // Duration of the maintenance period (e.g. 4h)
|
||||
Timezone string `yaml:"timezone"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
|
||||
Enabled *bool `yaml:"enabled"` // Whether the maintenance period is enabled. Enabled by default if nil.
|
||||
Start string `yaml:"start,omitempty"` // Time at which the maintenance period starts (e.g. 23:00)
|
||||
Duration time.Duration `yaml:"duration,omitempty"` // Duration of the maintenance period (e.g. 4h)
|
||||
Timezone string `yaml:"timezone,omitempty"` // Timezone in string format which the maintenance period is configured (e.g. America/Sao_Paulo)
|
||||
|
||||
// Every is a list of days of the week during which maintenance period applies.
|
||||
// See longDayNames for list of valid values.
|
||||
// Every day if empty.
|
||||
Every []string `yaml:"every"`
|
||||
Every []string `yaml:"every,omitempty"`
|
||||
|
||||
TimezoneLocation *time.Location // Timezone in location format which the maintenance period is configured
|
||||
timezoneLocation *time.Location
|
||||
durationToStartFromMidnight time.Duration
|
||||
}
|
||||
|
||||
@@ -90,13 +90,13 @@ func (c *Config) ValidateAndSetDefaults() error {
|
||||
return errInvalidMaintenanceDuration
|
||||
}
|
||||
if c.Timezone != "" {
|
||||
c.TimezoneLocation, err = time.LoadLocation(c.Timezone)
|
||||
c.timezoneLocation, err = time.LoadLocation(c.Timezone)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: %w", errInvalidTimezone, err)
|
||||
}
|
||||
} else {
|
||||
c.Timezone = "UTC"
|
||||
c.TimezoneLocation = time.UTC
|
||||
c.timezoneLocation = time.UTC
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -107,8 +107,8 @@ func (c *Config) IsUnderMaintenance() bool {
|
||||
return false
|
||||
}
|
||||
now := time.Now()
|
||||
if c.TimezoneLocation != nil {
|
||||
now = now.In(c.TimezoneLocation)
|
||||
if c.timezoneLocation != nil {
|
||||
now = now.In(c.timezoneLocation)
|
||||
}
|
||||
adjustedDate := now.Day()
|
||||
if now.Hour() < int(c.durationToStartFromMidnight.Hours()) {
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package suite
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
)
|
||||
|
||||
// Result represents the result of a suite execution
|
||||
type Result struct {
|
||||
// Name of the suite
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Group of the suite
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
// Success indicates whether all required endpoints succeeded
|
||||
Success bool `json:"success"`
|
||||
|
||||
// Timestamp is when the suite execution started
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
|
||||
// Duration is how long the entire suite execution took
|
||||
Duration time.Duration `json:"duration"`
|
||||
|
||||
// EndpointResults contains the results of each endpoint execution
|
||||
EndpointResults []*endpoint.Result `json:"endpointResults"`
|
||||
|
||||
// Context is the final state of the context after all endpoints executed
|
||||
Context map[string]interface{} `json:"-"`
|
||||
|
||||
// Errors contains any suite-level errors
|
||||
Errors []string `json:"errors,omitempty"`
|
||||
}
|
||||
|
||||
// AddError adds an error to the suite result
|
||||
func (r *Result) AddError(err string) {
|
||||
r.Errors = append(r.Errors, err)
|
||||
}
|
||||
|
||||
// CalculateSuccess determines if the suite execution was successful
|
||||
func (r *Result) CalculateSuccess() {
|
||||
r.Success = true
|
||||
// Check if any endpoints failed (all endpoints are required)
|
||||
for _, epResult := range r.EndpointResults {
|
||||
if !epResult.Success {
|
||||
r.Success = false
|
||||
break
|
||||
}
|
||||
}
|
||||
// Also check for suite-level errors
|
||||
if len(r.Errors) > 0 {
|
||||
r.Success = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package suite
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
"github.com/TwiN/gatus/v5/config/key"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrSuiteWithNoName is the error returned when a suite has no name
|
||||
ErrSuiteWithNoName = errors.New("suite must have a name")
|
||||
|
||||
// ErrSuiteWithNoEndpoints is the error returned when a suite has no endpoints
|
||||
ErrSuiteWithNoEndpoints = errors.New("suite must have at least one endpoint")
|
||||
|
||||
// ErrSuiteWithDuplicateEndpointNames is the error returned when a suite has duplicate endpoint names
|
||||
ErrSuiteWithDuplicateEndpointNames = errors.New("suite cannot have duplicate endpoint names")
|
||||
|
||||
// ErrSuiteWithInvalidTimeout is the error returned when a suite has an invalid timeout
|
||||
ErrSuiteWithInvalidTimeout = errors.New("suite timeout must be positive")
|
||||
|
||||
// DefaultInterval is the default interval for suite execution
|
||||
DefaultInterval = 10 * time.Minute
|
||||
|
||||
// DefaultTimeout is the default timeout for suite execution
|
||||
DefaultTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Suite is a collection of endpoints that are executed sequentially with shared context
|
||||
type Suite struct {
|
||||
// Name of the suite. Must be unique.
|
||||
Name string `yaml:"name"`
|
||||
|
||||
// Group the suite belongs to. Used for grouping multiple suites together.
|
||||
Group string `yaml:"group,omitempty"`
|
||||
|
||||
// Enabled defines whether the suite is enabled
|
||||
Enabled *bool `yaml:"enabled,omitempty"`
|
||||
|
||||
// Interval is the duration to wait between suite executions
|
||||
Interval time.Duration `yaml:"interval,omitempty"`
|
||||
|
||||
// Timeout is the maximum duration for the entire suite execution
|
||||
Timeout time.Duration `yaml:"timeout,omitempty"`
|
||||
|
||||
// InitialContext holds initial values that can be referenced by endpoints
|
||||
InitialContext map[string]interface{} `yaml:"context,omitempty"`
|
||||
|
||||
// Endpoints in the suite (executed sequentially)
|
||||
Endpoints []*endpoint.Endpoint `yaml:"endpoints"`
|
||||
}
|
||||
|
||||
// IsEnabled returns whether the suite is enabled
|
||||
func (s *Suite) IsEnabled() bool {
|
||||
if s.Enabled == nil {
|
||||
return true
|
||||
}
|
||||
return *s.Enabled
|
||||
}
|
||||
|
||||
// Key returns a unique key for the suite
|
||||
func (s *Suite) Key() string {
|
||||
return key.ConvertGroupAndNameToKey(s.Group, s.Name)
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the suite configuration and sets default values
|
||||
func (s *Suite) ValidateAndSetDefaults() error {
|
||||
// Validate name
|
||||
if len(s.Name) == 0 {
|
||||
return ErrSuiteWithNoName
|
||||
}
|
||||
// Validate endpoints
|
||||
if len(s.Endpoints) == 0 {
|
||||
return ErrSuiteWithNoEndpoints
|
||||
}
|
||||
// Check for duplicate endpoint names
|
||||
endpointNames := make(map[string]bool)
|
||||
for _, ep := range s.Endpoints {
|
||||
if endpointNames[ep.Name] {
|
||||
return fmt.Errorf("%w: duplicate endpoint name '%s'", ErrSuiteWithDuplicateEndpointNames, ep.Name)
|
||||
}
|
||||
endpointNames[ep.Name] = true
|
||||
// Suite endpoints inherit the group from the suite
|
||||
ep.Group = s.Group
|
||||
// Validate each endpoint
|
||||
if err := ep.ValidateAndSetDefaults(); err != nil {
|
||||
return fmt.Errorf("invalid endpoint '%s': %w", ep.Name, err)
|
||||
}
|
||||
}
|
||||
// Set default interval
|
||||
if s.Interval == 0 {
|
||||
s.Interval = DefaultInterval
|
||||
}
|
||||
// Set default timeout
|
||||
if s.Timeout == 0 {
|
||||
s.Timeout = DefaultTimeout
|
||||
}
|
||||
// Validate timeout
|
||||
if s.Timeout < 0 {
|
||||
return ErrSuiteWithInvalidTimeout
|
||||
}
|
||||
// Initialize context if nil
|
||||
if s.InitialContext == nil {
|
||||
s.InitialContext = make(map[string]interface{})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Execute executes all endpoints in the suite sequentially with context sharing
|
||||
func (s *Suite) Execute() *Result {
|
||||
start := time.Now()
|
||||
// Initialize context from suite configuration
|
||||
ctx := gontext.New(s.InitialContext)
|
||||
// Create suite result
|
||||
result := &Result{
|
||||
Name: s.Name,
|
||||
Group: s.Group,
|
||||
Success: true,
|
||||
Timestamp: start,
|
||||
EndpointResults: make([]*endpoint.Result, 0, len(s.Endpoints)),
|
||||
}
|
||||
// Set up timeout for the entire suite execution
|
||||
timeoutChan := time.After(s.Timeout)
|
||||
// Execute each endpoint sequentially
|
||||
suiteHasFailed := false
|
||||
for _, ep := range s.Endpoints {
|
||||
// Skip non-always-run endpoints if suite has already failed
|
||||
if suiteHasFailed && !ep.AlwaysRun {
|
||||
continue
|
||||
}
|
||||
// Check timeout
|
||||
select {
|
||||
case <-timeoutChan:
|
||||
result.AddError(fmt.Sprintf("suite execution timed out after %v", s.Timeout))
|
||||
result.Success = false
|
||||
break
|
||||
default:
|
||||
}
|
||||
// Execute endpoint with context
|
||||
epStartTime := time.Now()
|
||||
epResult := ep.EvaluateHealthWithContext(ctx)
|
||||
epDuration := time.Since(epStartTime)
|
||||
// Set endpoint name, timestamp, and duration on the result
|
||||
epResult.Name = ep.Name
|
||||
epResult.Timestamp = epStartTime
|
||||
epResult.Duration = epDuration
|
||||
// Store values from the endpoint result if configured (always store, even on failure)
|
||||
if ep.Store != nil {
|
||||
_, err := StoreResultValues(ctx, ep.Store, epResult)
|
||||
if err != nil {
|
||||
epResult.AddError(fmt.Sprintf("failed to store values: %v", err))
|
||||
}
|
||||
}
|
||||
result.EndpointResults = append(result.EndpointResults, epResult)
|
||||
// Mark suite as failed on any endpoint failure
|
||||
if !epResult.Success {
|
||||
result.Success = false
|
||||
suiteHasFailed = true
|
||||
}
|
||||
}
|
||||
result.Context = ctx.GetAll()
|
||||
result.Duration = time.Since(start)
|
||||
result.CalculateSuccess()
|
||||
return result
|
||||
}
|
||||
|
||||
// StoreResultValues extracts values from an endpoint result and stores them in the gontext
|
||||
func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result *endpoint.Result) (map[string]interface{}, error) {
|
||||
if mappings == nil || len(mappings) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
storedValues := make(map[string]interface{})
|
||||
var extractionErrors []string
|
||||
for contextKey, placeholder := range mappings {
|
||||
value, err := extractValueForStorage(placeholder, result)
|
||||
if err != nil {
|
||||
// Continue storing other values even if one fails
|
||||
extractionErrors = append(extractionErrors, fmt.Sprintf("%s: %v", contextKey, err))
|
||||
storedValues[contextKey] = fmt.Sprintf("ERROR: %v", err)
|
||||
continue
|
||||
}
|
||||
if err := ctx.Set(contextKey, value); err != nil {
|
||||
return storedValues, fmt.Errorf("failed to store %s: %w", contextKey, err)
|
||||
}
|
||||
storedValues[contextKey] = value
|
||||
}
|
||||
// Return an error if any values failed to extract
|
||||
if len(extractionErrors) > 0 {
|
||||
return storedValues, fmt.Errorf("failed to extract values: %s", strings.Join(extractionErrors, "; "))
|
||||
}
|
||||
return storedValues, nil
|
||||
}
|
||||
|
||||
// extractValueForStorage extracts a value from an endpoint result for storage in context
|
||||
func extractValueForStorage(placeholder string, result *endpoint.Result) (interface{}, error) {
|
||||
// Use the unified ResolvePlaceholder function (no context needed for extraction)
|
||||
resolved, err := endpoint.ResolvePlaceholder(placeholder, result, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Check if the resolution resulted in an INVALID placeholder
|
||||
// This happens when a path doesn't exist (e.g., [BODY].nonexistent)
|
||||
if strings.HasSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix) {
|
||||
return nil, fmt.Errorf("invalid path: %s", strings.TrimSuffix(resolved, " "+endpoint.InvalidConditionElementSuffix))
|
||||
}
|
||||
// Try to parse as number or boolean to store as proper types
|
||||
// Try int first for whole numbers
|
||||
if num, err := strconv.ParseInt(resolved, 10, 64); err == nil {
|
||||
return num, nil
|
||||
}
|
||||
// Then try float for decimals
|
||||
if num, err := strconv.ParseFloat(resolved, 64); err == nil {
|
||||
return num, nil
|
||||
}
|
||||
// Then try boolean
|
||||
if boolVal, err := strconv.ParseBool(resolved); err == nil {
|
||||
return boolVal, nil
|
||||
}
|
||||
return resolved, nil
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package suite
|
||||
|
||||
// Status represents the status of a suite
|
||||
type Status struct {
|
||||
// Name of the suite
|
||||
Name string `json:"name,omitempty"`
|
||||
|
||||
// Group the suite is a part of. Used for grouping multiple suites together on the front end.
|
||||
Group string `json:"group,omitempty"`
|
||||
|
||||
// Key of the Suite
|
||||
Key string `json:"key"`
|
||||
|
||||
// Results is the list of suite execution results
|
||||
Results []*Result `json:"results"`
|
||||
}
|
||||
|
||||
// NewStatus creates a new Status for a given Suite
|
||||
func NewStatus(s *Suite) *Status {
|
||||
return &Status{
|
||||
Name: s.Name,
|
||||
Group: s.Group,
|
||||
Key: s.Key(),
|
||||
Results: []*Result{},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,494 @@
|
||||
package suite
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/gontext"
|
||||
)
|
||||
|
||||
func TestSuite_ValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
suite *Suite
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "valid-suite",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "suite-without-name",
|
||||
suite: &Suite{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "suite-without-endpoints",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "suite-with-duplicate-endpoint-names",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "duplicate",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "duplicate",
|
||||
URL: "https://example.com",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.suite.ValidateAndSetDefaults()
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Suite.ValidateAndSetDefaults() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
// Check defaults were set
|
||||
if err == nil {
|
||||
if tt.suite.Interval == 0 {
|
||||
t.Errorf("Expected Interval to be set to default, got 0")
|
||||
}
|
||||
if tt.suite.Timeout == 0 {
|
||||
t.Errorf("Expected Timeout to be set to default, got 0")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_IsEnabled(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
enabled *bool
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "nil-defaults-to-true",
|
||||
enabled: nil,
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "explicitly-enabled",
|
||||
enabled: boolPtr(true),
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "explicitly-disabled",
|
||||
enabled: boolPtr(false),
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
s := &Suite{Enabled: tt.enabled}
|
||||
if got := s.IsEnabled(); got != tt.want {
|
||||
t.Errorf("Suite.IsEnabled() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_Key(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
suite *Suite
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "with-group",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Group: "test-group",
|
||||
},
|
||||
want: "test-group_test-suite",
|
||||
},
|
||||
{
|
||||
name: "without-group",
|
||||
suite: &Suite{
|
||||
Name: "test-suite",
|
||||
Group: "",
|
||||
},
|
||||
want: "_test-suite",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.suite.Key(); got != tt.want {
|
||||
t.Errorf("Suite.Key() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_DefaultValues(t *testing.T) {
|
||||
s := &Suite{
|
||||
Name: "test",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
err := s.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if s.Interval != DefaultInterval {
|
||||
t.Errorf("Expected Interval to be %v, got %v", DefaultInterval, s.Interval)
|
||||
}
|
||||
if s.Timeout != DefaultTimeout {
|
||||
t.Errorf("Expected Timeout to be %v, got %v", DefaultTimeout, s.Timeout)
|
||||
}
|
||||
if s.InitialContext == nil {
|
||||
t.Error("Expected InitialContext to be initialized, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to create bool pointers
|
||||
func boolPtr(b bool) *bool {
|
||||
return &b
|
||||
}
|
||||
|
||||
func TestStoreResultValues(t *testing.T) {
|
||||
ctx := gontext.New(nil)
|
||||
// Create a mock result
|
||||
result := &endpoint.Result{
|
||||
HTTPStatus: 200,
|
||||
IP: "192.168.1.1",
|
||||
Duration: 100 * time.Millisecond,
|
||||
Body: []byte(`{"status": "OK", "value": 42}`),
|
||||
Connected: true,
|
||||
}
|
||||
// Define store mappings
|
||||
mappings := map[string]string{
|
||||
"response_code": "[STATUS]",
|
||||
"server_ip": "[IP]",
|
||||
"response_time": "[RESPONSE_TIME]",
|
||||
"status": "[BODY].status",
|
||||
"value": "[BODY].value",
|
||||
"connected": "[CONNECTED]",
|
||||
}
|
||||
// Store values
|
||||
stored, err := StoreResultValues(ctx, mappings, result)
|
||||
if err != nil {
|
||||
t.Fatalf("Unexpected error storing values: %v", err)
|
||||
}
|
||||
// Verify stored values
|
||||
if stored["response_code"] != int64(200) {
|
||||
t.Errorf("Expected response_code=200, got %v", stored["response_code"])
|
||||
}
|
||||
if stored["server_ip"] != "192.168.1.1" {
|
||||
t.Errorf("Expected server_ip=192.168.1.1, got %v", stored["server_ip"])
|
||||
}
|
||||
if stored["status"] != "OK" {
|
||||
t.Errorf("Expected status=OK, got %v", stored["status"])
|
||||
}
|
||||
if stored["value"] != int64(42) { // Now parsed as int64 for whole numbers
|
||||
t.Errorf("Expected value=42, got %v", stored["value"])
|
||||
}
|
||||
if stored["connected"] != true {
|
||||
t.Errorf("Expected connected=true, got %v", stored["connected"])
|
||||
}
|
||||
// Verify values are in context
|
||||
val, err := ctx.Get("status")
|
||||
if err != nil || val != "OK" {
|
||||
t.Errorf("Expected status=OK in context, got %v, err=%v", val, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStoreResultValuesWithInvalidPath(t *testing.T) {
|
||||
ctx := gontext.New(map[string]interface{}{})
|
||||
result := &endpoint.Result{
|
||||
HTTPStatus: 200,
|
||||
Body: []byte(`{"data": {"name": "john"}}`),
|
||||
}
|
||||
// Define store mappings with invalid paths
|
||||
mappings := map[string]string{
|
||||
"valid_status": "[STATUS]",
|
||||
"invalid_token": "[BODY].accessToken", // This path doesn't exist
|
||||
"invalid_nested": "[BODY].user.id.invalid", // This nested path doesn't exist
|
||||
}
|
||||
// Store values - should return error for invalid paths
|
||||
stored, err := StoreResultValues(ctx, mappings, result)
|
||||
if err == nil {
|
||||
t.Fatal("Expected error when storing invalid paths, got nil")
|
||||
}
|
||||
// Check that the error message contains information about the invalid paths
|
||||
if !strings.Contains(err.Error(), "invalid_token") {
|
||||
t.Errorf("Error should mention invalid_token, got: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid path") {
|
||||
t.Errorf("Error should mention 'invalid path', got: %v", err)
|
||||
}
|
||||
// Verify that valid values were still stored
|
||||
if stored["valid_status"] != int64(200) {
|
||||
t.Errorf("Expected valid_status=200, got %v", stored["valid_status"])
|
||||
}
|
||||
// Verify that invalid values show error messages in stored map
|
||||
if !strings.Contains(stored["invalid_token"].(string), "ERROR") {
|
||||
t.Errorf("Expected invalid_token to contain ERROR, got %v", stored["invalid_token"])
|
||||
}
|
||||
// Verify that invalid values are NOT in context
|
||||
_, err = ctx.Get("invalid_token")
|
||||
if err == nil {
|
||||
t.Error("Invalid token should not be stored in context")
|
||||
}
|
||||
// Verify that valid value IS in context
|
||||
val, err := ctx.Get("valid_status")
|
||||
if err != nil || val != int64(200) {
|
||||
t.Errorf("Expected valid_status=200 in context, got %v, err=%v", val, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_ExecuteWithAlwaysRunEndpoints(t *testing.T) {
|
||||
suite := &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "create-resource",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
Store: map[string]string{
|
||||
"created_id": "[BODY]",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "failing-endpoint",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] != 200"), // This will fail
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "cleanup-resource",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
AlwaysRun: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := suite.ValidateAndSetDefaults(); err != nil {
|
||||
t.Fatalf("suite validation failed: %v", err)
|
||||
}
|
||||
result := suite.Execute()
|
||||
if result.Success {
|
||||
t.Error("expected suite to fail due to middle endpoint failure")
|
||||
}
|
||||
if len(result.EndpointResults) != 3 {
|
||||
t.Errorf("expected 3 endpoint results, got %d", len(result.EndpointResults))
|
||||
}
|
||||
if result.EndpointResults[0].Name != "create-resource" {
|
||||
t.Errorf("expected first endpoint to be 'create-resource', got '%s'", result.EndpointResults[0].Name)
|
||||
}
|
||||
if result.EndpointResults[1].Name != "failing-endpoint" {
|
||||
t.Errorf("expected second endpoint to be 'failing-endpoint', got '%s'", result.EndpointResults[1].Name)
|
||||
}
|
||||
if result.EndpointResults[1].Success {
|
||||
t.Error("expected failing-endpoint to fail")
|
||||
}
|
||||
if result.EndpointResults[2].Name != "cleanup-resource" {
|
||||
t.Errorf("expected third endpoint to be 'cleanup-resource', got '%s'", result.EndpointResults[2].Name)
|
||||
}
|
||||
if !result.EndpointResults[2].Success {
|
||||
t.Error("expected cleanup endpoint to succeed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuite_ExecuteWithoutAlwaysRunEndpoints(t *testing.T) {
|
||||
suite := &Suite{
|
||||
Name: "test-suite",
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "create-resource",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "failing-endpoint",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] != 200"), // This will fail
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "skipped-endpoint",
|
||||
URL: "https://example.org",
|
||||
Conditions: []endpoint.Condition{
|
||||
endpoint.Condition("[STATUS] == 200"),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := suite.ValidateAndSetDefaults(); err != nil {
|
||||
t.Fatalf("suite validation failed: %v", err)
|
||||
}
|
||||
result := suite.Execute()
|
||||
if result.Success {
|
||||
t.Error("expected suite to fail due to middle endpoint failure")
|
||||
}
|
||||
if len(result.EndpointResults) != 2 {
|
||||
t.Errorf("expected 2 endpoint results (execution should stop after failure), got %d", len(result.EndpointResults))
|
||||
}
|
||||
if result.EndpointResults[0].Name != "create-resource" {
|
||||
t.Errorf("expected first endpoint to be 'create-resource', got '%s'", result.EndpointResults[0].Name)
|
||||
}
|
||||
if result.EndpointResults[1].Name != "failing-endpoint" {
|
||||
t.Errorf("expected second endpoint to be 'failing-endpoint', got '%s'", result.EndpointResults[1].Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResult_AddError(t *testing.T) {
|
||||
result := &Result{
|
||||
Name: "test-suite",
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
if len(result.Errors) != 0 {
|
||||
t.Errorf("Expected 0 errors initially, got %d", len(result.Errors))
|
||||
}
|
||||
result.AddError("first error")
|
||||
if len(result.Errors) != 1 {
|
||||
t.Errorf("Expected 1 error after AddError, got %d", len(result.Errors))
|
||||
}
|
||||
if result.Errors[0] != "first error" {
|
||||
t.Errorf("Expected 'first error', got '%s'", result.Errors[0])
|
||||
}
|
||||
result.AddError("second error")
|
||||
if len(result.Errors) != 2 {
|
||||
t.Errorf("Expected 2 errors after second AddError, got %d", len(result.Errors))
|
||||
}
|
||||
if result.Errors[1] != "second error" {
|
||||
t.Errorf("Expected 'second error', got '%s'", result.Errors[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestResult_CalculateSuccess(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
endpointResults []*endpoint.Result
|
||||
errors []string
|
||||
expectedSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "no-endpoints-no-errors",
|
||||
endpointResults: []*endpoint.Result{},
|
||||
errors: []string{},
|
||||
expectedSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "all-endpoints-successful-no-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: true},
|
||||
{Success: true},
|
||||
},
|
||||
errors: []string{},
|
||||
expectedSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "second-endpoint-failed-no-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: true},
|
||||
{Success: false},
|
||||
},
|
||||
errors: []string{},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "first-endpoint-failed-no-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: false},
|
||||
{Success: true},
|
||||
},
|
||||
errors: []string{},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "all-endpoints-successful-with-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: true},
|
||||
{Success: true},
|
||||
},
|
||||
errors: []string{"suite level error"},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "endpoint-failed-and-errors",
|
||||
endpointResults: []*endpoint.Result{
|
||||
{Success: true},
|
||||
{Success: false},
|
||||
},
|
||||
errors: []string{"suite level error"},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "no-endpoints-with-errors",
|
||||
endpointResults: []*endpoint.Result{},
|
||||
errors: []string{"configuration error"},
|
||||
expectedSuccess: false,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := &Result{
|
||||
Name: "test-suite",
|
||||
Timestamp: time.Now(),
|
||||
EndpointResults: tt.endpointResults,
|
||||
Errors: tt.errors,
|
||||
}
|
||||
result.CalculateSuccess()
|
||||
if result.Success != tt.expectedSuccess {
|
||||
t.Errorf("Expected success=%v, got %v", tt.expectedSuccess, result.Success)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package sshtunnel
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"golang.org/x/crypto/ssh"
|
||||
)
|
||||
|
||||
// Config represents the configuration for an SSH tunnel
|
||||
type Config struct {
|
||||
Type string `yaml:"type"`
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port,omitempty"`
|
||||
Username string `yaml:"username"`
|
||||
PrivateKey string `yaml:"private-key,omitempty"`
|
||||
Password string `yaml:"password,omitempty"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the SSH tunnel configuration and sets defaults
|
||||
func (c *Config) ValidateAndSetDefaults() error {
|
||||
if c.Type != "SSH" {
|
||||
return fmt.Errorf("unsupported tunnel type: %s", c.Type)
|
||||
}
|
||||
if c.Host == "" {
|
||||
return fmt.Errorf("host is required")
|
||||
}
|
||||
if c.Username == "" {
|
||||
return fmt.Errorf("username is required")
|
||||
}
|
||||
if c.PrivateKey == "" && c.Password == "" {
|
||||
return fmt.Errorf("either private-key or password is required")
|
||||
}
|
||||
if c.Port == 0 {
|
||||
c.Port = 22
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SSHTunnel represents an SSH tunnel connection
|
||||
type SSHTunnel struct {
|
||||
config *Config
|
||||
mu sync.RWMutex
|
||||
client *ssh.Client
|
||||
|
||||
// Cached authentication methods to avoid reparsing private keys
|
||||
authMethods []ssh.AuthMethod
|
||||
}
|
||||
|
||||
// New creates a new SSH tunnel with the given configuration
|
||||
func New(config *Config) *SSHTunnel {
|
||||
tunnel := &SSHTunnel{
|
||||
config: config,
|
||||
}
|
||||
// Parse authentication methods once during initialization to avoid
|
||||
// expensive cryptographic operations on every connection attempt
|
||||
if config.PrivateKey != "" {
|
||||
if signer, err := ssh.ParsePrivateKey([]byte(config.PrivateKey)); err == nil {
|
||||
tunnel.authMethods = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||
}
|
||||
// Note: We don't return error here to maintain backward compatibility.
|
||||
// Invalid keys will be caught during first connection attempt.
|
||||
} else if config.Password != "" {
|
||||
tunnel.authMethods = []ssh.AuthMethod{ssh.Password(config.Password)}
|
||||
}
|
||||
return tunnel
|
||||
}
|
||||
|
||||
// Connect establishes the SSH connection
|
||||
func (t *SSHTunnel) Connect() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
return t.connectUnsafe()
|
||||
}
|
||||
|
||||
// connectUnsafe establishes the SSH connection without acquiring locks
|
||||
// Must be called with t.mu.Lock() already held
|
||||
func (t *SSHTunnel) connectUnsafe() error {
|
||||
// Use cached authentication methods to avoid expensive crypto operations
|
||||
if len(t.authMethods) == 0 {
|
||||
return fmt.Errorf("no authentication method available")
|
||||
}
|
||||
config := &ssh.ClientConfig{
|
||||
User: t.config.Username,
|
||||
Timeout: 30 * time.Second,
|
||||
HostKeyCallback: ssh.InsecureIgnoreHostKey(), // Skip host key verification
|
||||
Auth: t.authMethods, // Use pre-parsed authentication
|
||||
}
|
||||
// Connect to SSH server
|
||||
addr := fmt.Sprintf("%s:%d", t.config.Host, t.config.Port)
|
||||
client, err := ssh.Dial("tcp", addr, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("SSH connection failed: %w", err)
|
||||
}
|
||||
t.client = client
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close closes the SSH connection
|
||||
func (t *SSHTunnel) Close() error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
if t.client != nil {
|
||||
err := t.client.Close()
|
||||
t.client = nil
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Dial creates a connection through the SSH tunnel
|
||||
func (t *SSHTunnel) Dial(network, addr string) (net.Conn, error) {
|
||||
t.mu.RLock()
|
||||
client := t.client
|
||||
t.mu.RUnlock()
|
||||
// Ensure we have an SSH connection
|
||||
if client == nil {
|
||||
// Use write lock to prevent race condition during connection
|
||||
t.mu.Lock()
|
||||
// Double-check client after acquiring lock
|
||||
if t.client == nil {
|
||||
if err := t.connectUnsafe(); err != nil {
|
||||
t.mu.Unlock()
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
client = t.client
|
||||
t.mu.Unlock()
|
||||
}
|
||||
// Attempt dial with exponential backoff retry
|
||||
const maxRetries = 3
|
||||
const baseDelay = 500 * time.Millisecond
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < maxRetries; attempt++ {
|
||||
if attempt > 0 {
|
||||
// Exponential backoff: 500ms, 1s, 2s
|
||||
delay := baseDelay << (attempt - 1)
|
||||
time.Sleep(delay)
|
||||
// Close stale connection and reconnect
|
||||
t.mu.Lock()
|
||||
if t.client != nil {
|
||||
_ = t.client.Close()
|
||||
t.client = nil
|
||||
}
|
||||
if err := t.connectUnsafe(); err != nil {
|
||||
t.mu.Unlock()
|
||||
lastErr = fmt.Errorf("reconnect attempt %d failed: %w", attempt, err)
|
||||
continue
|
||||
}
|
||||
client = t.client
|
||||
t.mu.Unlock()
|
||||
}
|
||||
conn, err := client.Dial(network, addr)
|
||||
if err == nil {
|
||||
return conn, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return nil, fmt.Errorf("SSH tunnel dial failed after %d attempts: %w", maxRetries, lastErr)
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package sshtunnel
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid SSH config with private key",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
PrivateKey: "-----BEGIN RSA PRIVATE KEY-----\ntest\n-----END RSA PRIVATE KEY-----",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid SSH config with password",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid SSH config with custom port",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Port: 2222,
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "sets default port 22",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid type",
|
||||
config: &Config{
|
||||
Type: "INVALID",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "unsupported tunnel type: INVALID",
|
||||
},
|
||||
{
|
||||
name: "missing host",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "host is required",
|
||||
},
|
||||
{
|
||||
name: "missing username",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Password: "secret",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "username is required",
|
||||
},
|
||||
{
|
||||
name: "missing authentication",
|
||||
config: &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "either private-key or password is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
originalPort := tt.config.Port
|
||||
err := tt.config.ValidateAndSetDefaults()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ValidateAndSetDefaults() expected error but got none")
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
// Check that default port is set
|
||||
if originalPort == 0 && tt.config.Port != 22 {
|
||||
t.Errorf("ValidateAndSetDefaults() expected default port 22, got %d", tt.config.Port)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew(t *testing.T) {
|
||||
config := &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
}
|
||||
tunnel := New(config)
|
||||
if tunnel == nil {
|
||||
t.Error("New() returned nil")
|
||||
return
|
||||
}
|
||||
if tunnel.config != config {
|
||||
t.Error("New() did not set config correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSHTunnel_Close(t *testing.T) {
|
||||
config := &Config{
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
}
|
||||
tunnel := New(config)
|
||||
// Test closing when no client is set
|
||||
err := tunnel.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() with no client returned error: %v", err)
|
||||
}
|
||||
// Test closing multiple times
|
||||
err = tunnel.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() called twice returned error: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package tunneling
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
|
||||
)
|
||||
|
||||
// Config represents the tunneling configuration
|
||||
type Config struct {
|
||||
// Tunnels is a map of SSH tunnel configurations in which the key is the name of the tunnel
|
||||
Tunnels map[string]*sshtunnel.Config `yaml:",inline"`
|
||||
|
||||
mu sync.RWMutex `yaml:"-"`
|
||||
connections map[string]*sshtunnel.SSHTunnel `yaml:"-"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the tunneling configuration and sets defaults
|
||||
func (tc *Config) ValidateAndSetDefaults() error {
|
||||
if tc.connections == nil {
|
||||
tc.connections = make(map[string]*sshtunnel.SSHTunnel)
|
||||
}
|
||||
for name, config := range tc.Tunnels {
|
||||
if err := config.ValidateAndSetDefaults(); err != nil {
|
||||
return fmt.Errorf("tunnel '%s': %w", name, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetTunnel returns the SSH tunnel for the given name, creating it if necessary
|
||||
func (tc *Config) GetTunnel(name string) (*sshtunnel.SSHTunnel, error) {
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("tunnel name cannot be empty")
|
||||
}
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
// Check if tunnel already exists
|
||||
if tunnel, exists := tc.connections[name]; exists {
|
||||
return tunnel, nil
|
||||
}
|
||||
// Get config for this tunnel
|
||||
config, exists := tc.Tunnels[name]
|
||||
if !exists {
|
||||
return nil, fmt.Errorf("tunnel '%s' not found in configuration", name)
|
||||
}
|
||||
// Create and store new tunnel
|
||||
tunnel := sshtunnel.New(config)
|
||||
tc.connections[name] = tunnel
|
||||
return tunnel, nil
|
||||
}
|
||||
|
||||
// Close closes all SSH tunnel connections
|
||||
func (tc *Config) Close() error {
|
||||
tc.mu.Lock()
|
||||
defer tc.mu.Unlock()
|
||||
var errors []string
|
||||
for name, tunnel := range tc.connections {
|
||||
if err := tunnel.Close(); err != nil {
|
||||
errors = append(errors, fmt.Sprintf("tunnel '%s': %v", name, err))
|
||||
}
|
||||
delete(tc.connections, name)
|
||||
}
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("failed to close tunnels: %s", strings.Join(errors, ", "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package tunneling
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config/tunneling/sshtunnel"
|
||||
)
|
||||
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid config with SSH tunnel",
|
||||
config: &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "multiple valid tunnels",
|
||||
config: &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"tunnel1": {
|
||||
Type: "SSH",
|
||||
Host: "host1.com",
|
||||
Username: "user1",
|
||||
PrivateKey: "key1",
|
||||
},
|
||||
"tunnel2": {
|
||||
Type: "SSH",
|
||||
Host: "host2.com",
|
||||
Username: "user2",
|
||||
Password: "pass2",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid tunnel config",
|
||||
config: &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"invalid": {
|
||||
Type: "INVALID",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "tunnel 'invalid': unsupported tunnel type: INVALID",
|
||||
},
|
||||
{
|
||||
name: "missing host in tunnel",
|
||||
config: &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"nohost": {
|
||||
Type: "SSH",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: "tunnel 'nohost': host is required",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.config.ValidateAndSetDefaults()
|
||||
if tt.wantErr {
|
||||
if err == nil {
|
||||
t.Errorf("ValidateAndSetDefaults() expected error but got none")
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.errMsg {
|
||||
t.Errorf("ValidateAndSetDefaults() error = %v, want %v", err.Error(), tt.errMsg)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Errorf("ValidateAndSetDefaults() unexpected error = %v", err)
|
||||
return
|
||||
}
|
||||
// Check that connections map is initialized
|
||||
if tt.config != nil && tt.config.connections == nil {
|
||||
t.Error("ValidateAndSetDefaults() did not initialize connections map")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_GetTunnel(t *testing.T) {
|
||||
config := &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test": {
|
||||
Type: "SSH",
|
||||
Host: "example.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := config.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
|
||||
}
|
||||
// Test getting existing tunnel
|
||||
tunnel1, err := config.GetTunnel("test")
|
||||
if err != nil {
|
||||
t.Errorf("GetTunnel() error = %v", err)
|
||||
return
|
||||
}
|
||||
if tunnel1 == nil {
|
||||
t.Error("GetTunnel() returned nil tunnel")
|
||||
return
|
||||
}
|
||||
// Test getting same tunnel again (should return same instance)
|
||||
tunnel2, err := config.GetTunnel("test")
|
||||
if err != nil {
|
||||
t.Errorf("GetTunnel() second call error = %v", err)
|
||||
return
|
||||
}
|
||||
if tunnel1 != tunnel2 {
|
||||
t.Error("GetTunnel() should return same instance for same tunnel name")
|
||||
}
|
||||
// Test getting non-existent tunnel
|
||||
_, err = config.GetTunnel("nonexistent")
|
||||
if err == nil {
|
||||
t.Error("GetTunnel() expected error for non-existent tunnel")
|
||||
return
|
||||
}
|
||||
expectedErr := "tunnel 'nonexistent' not found in configuration"
|
||||
if err.Error() != expectedErr {
|
||||
t.Errorf("GetTunnel() error = %v, want %v", err.Error(), expectedErr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_Close(t *testing.T) {
|
||||
// Test closing config with tunnels
|
||||
config := &Config{
|
||||
Tunnels: map[string]*sshtunnel.Config{
|
||||
"test1": {
|
||||
Type: "SSH",
|
||||
Host: "example1.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
"test2": {
|
||||
Type: "SSH",
|
||||
Host: "example2.com",
|
||||
Username: "test",
|
||||
Password: "secret",
|
||||
},
|
||||
},
|
||||
}
|
||||
err := config.ValidateAndSetDefaults()
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateAndSetDefaults() failed: %v", err)
|
||||
}
|
||||
// Create some tunnels
|
||||
_, err = config.GetTunnel("test1")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTunnel() failed: %v", err)
|
||||
}
|
||||
_, err = config.GetTunnel("test2")
|
||||
if err != nil {
|
||||
t.Fatalf("GetTunnel() failed: %v", err)
|
||||
}
|
||||
// Test closing
|
||||
err = config.Close()
|
||||
if err != nil {
|
||||
t.Errorf("Close() returned error: %v", err)
|
||||
}
|
||||
// Verify connections map is empty
|
||||
if len(config.connections) != 0 {
|
||||
t.Errorf("Close() did not clear connections map, got %d connections", len(config.connections))
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,14 @@
|
||||
module github.com/TwiN/gatus/v5
|
||||
|
||||
go 1.24.1
|
||||
go 1.24.4
|
||||
|
||||
toolchain go1.24.7
|
||||
|
||||
require (
|
||||
code.gitea.io/sdk/gitea v0.21.0
|
||||
github.com/TwiN/deepmerge v0.2.2
|
||||
github.com/TwiN/g8/v2 v2.0.0
|
||||
github.com/TwiN/gocache/v2 v2.2.2
|
||||
github.com/TwiN/gocache/v2 v2.4.0
|
||||
github.com/TwiN/health v1.6.0
|
||||
github.com/TwiN/logr v0.3.1
|
||||
github.com/TwiN/whois v1.1.11
|
||||
@@ -17,14 +19,16 @@ require (
|
||||
github.com/google/uuid v1.6.0
|
||||
github.com/ishidawataru/sctp v0.0.0-20230406120618-7ff4192f6ff2
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/miekg/dns v1.1.67
|
||||
github.com/prometheus-community/pro-bing v0.6.1
|
||||
github.com/prometheus/client_golang v1.22.0
|
||||
github.com/miekg/dns v1.1.68
|
||||
github.com/prometheus-community/pro-bing v0.7.0
|
||||
github.com/prometheus/client_golang v1.23.0
|
||||
github.com/registrobr/rdap v1.1.8
|
||||
github.com/valyala/fasthttp v1.64.0
|
||||
github.com/wcharczuk/go-chart/v2 v2.1.2
|
||||
golang.org/x/crypto v0.40.0
|
||||
golang.org/x/net v0.42.0
|
||||
golang.org/x/oauth2 v0.30.0
|
||||
golang.org/x/sync v0.17.0
|
||||
google.golang.org/api v0.242.0
|
||||
gopkg.in/mail.v2 v2.3.1
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -60,9 +64,9 @@ require (
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
|
||||
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||
github.com/prometheus/client_model v0.6.1 // indirect
|
||||
github.com/prometheus/common v0.62.0 // indirect
|
||||
github.com/prometheus/procfs v0.15.1 // indirect
|
||||
github.com/prometheus/client_model v0.6.2 // indirect
|
||||
github.com/prometheus/common v0.65.0 // indirect
|
||||
github.com/prometheus/procfs v0.16.1 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/valyala/bytebufferpool v1.0.0 // indirect
|
||||
@@ -74,7 +78,6 @@ require (
|
||||
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
|
||||
golang.org/x/image v0.18.0 // indirect
|
||||
golang.org/x/mod v0.25.0 // indirect
|
||||
golang.org/x/sync v0.16.0 // indirect
|
||||
golang.org/x/sys v0.34.0 // indirect
|
||||
golang.org/x/text v0.27.0 // indirect
|
||||
golang.org/x/tools v0.34.0 // indirect
|
||||
|
||||
@@ -12,8 +12,8 @@ github.com/TwiN/deepmerge v0.2.2 h1:FUG9QMIYg/j2aQyPPhA3XTFJwXSNHI/swaR4Lbyxwg4=
|
||||
github.com/TwiN/deepmerge v0.2.2/go.mod h1:4OHvjV3pPNJCJZBHswYAwk6rxiD8h8YZ+9cPo7nu4oI=
|
||||
github.com/TwiN/g8/v2 v2.0.0 h1:+hwIbRLMhDd2iwHzkZUPp2FkX7yTx8ddYOnS91HkDqQ=
|
||||
github.com/TwiN/g8/v2 v2.0.0/go.mod h1:4sVAF27q8T8ISggRa/Fb0drw7wpB22B6eWd+/+SGMqE=
|
||||
github.com/TwiN/gocache/v2 v2.2.2 h1:4HToPfDV8FSbaYO5kkbhLpEllUYse5rAf+hVU/mSsuI=
|
||||
github.com/TwiN/gocache/v2 v2.2.2/go.mod h1:WfIuwd7GR82/7EfQqEtmLFC3a2vqaKbs4Pe6neB7Gyc=
|
||||
github.com/TwiN/gocache/v2 v2.4.0 h1:BZ/TqvhipDQE23MFFTjC0MiI1qZ7GEVtSdOFVVXyr18=
|
||||
github.com/TwiN/gocache/v2 v2.4.0/go.mod h1:Cl1c0qNlQlXzJhTpAARVqpQDSuGDM5RhtzPYAM1x17g=
|
||||
github.com/TwiN/health v1.6.0 h1:L2ks575JhRgQqWWOfKjw9B0ec172hx7GdToqkYUycQM=
|
||||
github.com/TwiN/health v1.6.0/go.mod h1:Z6TszwQPMvtSiVx1QMidVRgvVr4KZGfiwqcD7/Z+3iw=
|
||||
github.com/TwiN/logr v0.3.1 h1:CfTKA83jUmsAoxqrr3p4JxEkqXOBnEE9/f35L5MODy4=
|
||||
@@ -22,6 +22,8 @@ github.com/TwiN/whois v1.1.11 h1:lYiYgPRSQ3kH8sQfgHcBY/uNSGGvWPRikEjn+LJZ9+Q=
|
||||
github.com/TwiN/whois v1.1.11/go.mod h1:TjipCMpJRAJYKmtz/rXQBU6UGxMh6bk8SHazu7OMnQE=
|
||||
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
|
||||
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
|
||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b h1:uUXgbcPDK3KpW29o4iy7GtuappbWT0l5NaMo9H9pJDw=
|
||||
github.com/aryann/difflib v0.0.0-20210328193216-ff5ff6dc229b/go.mod h1:DAHtR1m6lCRdSC2Tm3DSWRPvIPr6xNKyeHdqDQSQT+A=
|
||||
github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ=
|
||||
github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk=
|
||||
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
@@ -97,24 +99,26 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/miekg/dns v1.1.67 h1:kg0EHj0G4bfT5/oOys6HhZw4vmMlnoZ+gDu8tJ/AlI0=
|
||||
github.com/miekg/dns v1.1.67/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/miekg/dns v1.1.68 h1:jsSRkNozw7G/mnmXULynzMNIsgY2dHC8LO6U6Ij2JEA=
|
||||
github.com/miekg/dns v1.1.68/go.mod h1:fujopn7TB3Pu3JM69XaawiU0wqjpL9/8xGop5UrTPps=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
|
||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
|
||||
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus-community/pro-bing v0.6.1 h1:EQukUOma9YFZRPe4DGSscxUf9LH07rpqwisNWjSZrgU=
|
||||
github.com/prometheus-community/pro-bing v0.6.1/go.mod h1:jNCOI3D7pmTCeaoF41cNS6uaxeFY/Gmc3ffwbuJVzAQ=
|
||||
github.com/prometheus/client_golang v1.22.0 h1:rb93p9lokFEsctTys46VnV1kLCDpVZ0a/Y92Vm0Zc6Q=
|
||||
github.com/prometheus/client_golang v1.22.0/go.mod h1:R7ljNsLXhuQXYZYtw6GAE9AZg8Y7vEW5scdCXrWRXC0=
|
||||
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
|
||||
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
|
||||
github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
|
||||
github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
|
||||
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
|
||||
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
|
||||
github.com/prometheus-community/pro-bing v0.7.0 h1:KFYFbxC2f2Fp6c+TyxbCOEarf7rbnzr9Gw8eIb0RfZA=
|
||||
github.com/prometheus-community/pro-bing v0.7.0/go.mod h1:Moob9dvlY50Bfq6i88xIwfyw7xLFHH69LUgx9n5zqCE=
|
||||
github.com/prometheus/client_golang v1.23.0 h1:ust4zpdl9r4trLY/gSjlm07PuiBq2ynaXXlptpfy8Uc=
|
||||
github.com/prometheus/client_golang v1.23.0/go.mod h1:i/o0R9ByOnHX0McrTMTyhYvKE4haaf2mW08I+jGAjEE=
|
||||
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
|
||||
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
|
||||
github.com/prometheus/common v0.65.0 h1:QDwzd+G1twt//Kwj/Ww6E9FQq1iVMmODnILtW1t2VzE=
|
||||
github.com/prometheus/common v0.65.0/go.mod h1:0gZns+BLRQ3V6NdaerOhMbwwRbNh9hkGINtQAsP5GS8=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/registrobr/rdap v1.1.8 h1:7egYAM8MsuencdP9mvF/892f8OjXvUFSyp5cT1Lg45U=
|
||||
github.com/registrobr/rdap v1.1.8/go.mod h1:VY2DVrpsJpUfy9gj2QvurGymCgZV11/11cxQz5CxO+w=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
@@ -150,6 +154,8 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw
|
||||
go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4=
|
||||
go.opentelemetry.io/otel/trace v1.36.0 h1:ahxWNuqZjpdiFAyrIoQ4GIiAIhxAunQR6MUoKrsNd4w=
|
||||
go.opentelemetry.io/otel/trace v1.36.0/go.mod h1:gQ+OnDZzrybY4k4seLzPAWNwVBBVlF2szhehOBB/tGA=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
@@ -189,8 +195,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
|
||||
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
|
||||
@@ -59,6 +59,7 @@ func stop(cfg *config.Config) {
|
||||
watchdog.Shutdown(cfg)
|
||||
controller.Shutdown()
|
||||
metrics.UnregisterPrometheusMetrics()
|
||||
closeTunnels(cfg)
|
||||
}
|
||||
|
||||
func save() {
|
||||
@@ -103,6 +104,15 @@ func initializeStorage(cfg *config.Config) {
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
// Remove all SuiteStatuses that represent suites which no longer exist in the configuration
|
||||
var suiteKeys []string
|
||||
for _, suite := range cfg.Suites {
|
||||
suiteKeys = append(suiteKeys, suite.Key())
|
||||
}
|
||||
numberOfSuiteStatusesDeleted := store.Get().DeleteAllSuiteStatusesNotInKeys(suiteKeys)
|
||||
if numberOfSuiteStatusesDeleted > 0 {
|
||||
logr.Infof("[main.initializeStorage] Deleted %d suite statuses because their matching suites no longer existed", numberOfSuiteStatusesDeleted)
|
||||
}
|
||||
// Remove all EndpointStatus that represent endpoints which no longer exist in the configuration
|
||||
var keys []string
|
||||
for _, ep := range cfg.Endpoints {
|
||||
@@ -111,6 +121,13 @@ func initializeStorage(cfg *config.Config) {
|
||||
for _, ee := range cfg.ExternalEndpoints {
|
||||
keys = append(keys, ee.Key())
|
||||
}
|
||||
// Also add endpoints that are part of suites
|
||||
for _, suite := range cfg.Suites {
|
||||
for _, ep := range suite.Endpoints {
|
||||
keys = append(keys, ep.Key())
|
||||
}
|
||||
}
|
||||
logr.Infof("[main.initializeStorage] Total endpoint keys to preserve: %d", len(keys))
|
||||
numberOfEndpointStatusesDeleted := store.Get().DeleteAllEndpointStatusesNotInKeys(keys)
|
||||
if numberOfEndpointStatusesDeleted > 0 {
|
||||
logr.Infof("[main.initializeStorage] Deleted %d endpoint statuses because their matching endpoints no longer existed", numberOfEndpointStatusesDeleted)
|
||||
@@ -171,6 +188,14 @@ func initializeStorage(cfg *config.Config) {
|
||||
}
|
||||
}
|
||||
|
||||
func closeTunnels(cfg *config.Config) {
|
||||
if cfg.Tunneling != nil {
|
||||
if err := cfg.Tunneling.Close(); err != nil {
|
||||
logr.Errorf("[main.closeTunnels] Error closing SSH tunnels: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func listenToConfigurationFileChanges(cfg *config.Config) {
|
||||
for {
|
||||
time.Sleep(30 * time.Second)
|
||||
|
||||
+72
-2
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
)
|
||||
|
||||
@@ -18,6 +19,11 @@ var (
|
||||
resultCertificateExpirationSeconds *prometheus.GaugeVec
|
||||
resultEndpointSuccess *prometheus.GaugeVec
|
||||
|
||||
// Suite metrics
|
||||
suiteResultTotal *prometheus.CounterVec
|
||||
suiteResultDurationSeconds *prometheus.GaugeVec
|
||||
suiteResultSuccess *prometheus.GaugeVec
|
||||
|
||||
// Track if metrics have been initialized to prevent duplicate registration
|
||||
metricsInitialized bool
|
||||
currentRegisterer prometheus.Registerer
|
||||
@@ -49,6 +55,17 @@ func UnregisterPrometheusMetrics() {
|
||||
currentRegisterer.Unregister(resultEndpointSuccess)
|
||||
}
|
||||
|
||||
// Unregister suite metrics
|
||||
if suiteResultTotal != nil {
|
||||
currentRegisterer.Unregister(suiteResultTotal)
|
||||
}
|
||||
if suiteResultDurationSeconds != nil {
|
||||
currentRegisterer.Unregister(suiteResultDurationSeconds)
|
||||
}
|
||||
if suiteResultSuccess != nil {
|
||||
currentRegisterer.Unregister(suiteResultSuccess)
|
||||
}
|
||||
|
||||
metricsInitialized = false
|
||||
currentRegisterer = nil
|
||||
}
|
||||
@@ -109,6 +126,28 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
|
||||
}, append([]string{"key", "group", "name", "type"}, extraLabels...))
|
||||
reg.MustRegister(resultEndpointSuccess)
|
||||
|
||||
// Suite metrics
|
||||
suiteResultTotal = prometheus.NewCounterVec(prometheus.CounterOpts{
|
||||
Namespace: namespace,
|
||||
Name: "suite_results_total",
|
||||
Help: "Total number of suite executions",
|
||||
}, append([]string{"key", "group", "name", "success"}, extraLabels...))
|
||||
reg.MustRegister(suiteResultTotal)
|
||||
|
||||
suiteResultDurationSeconds = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "suite_results_duration_seconds",
|
||||
Help: "Duration of suite execution in seconds",
|
||||
}, append([]string{"key", "group", "name"}, extraLabels...))
|
||||
reg.MustRegister(suiteResultDurationSeconds)
|
||||
|
||||
suiteResultSuccess = prometheus.NewGaugeVec(prometheus.GaugeOpts{
|
||||
Namespace: namespace,
|
||||
Name: "suite_results_success",
|
||||
Help: "Whether the suite execution was successful (1) or not (0)",
|
||||
}, append([]string{"key", "group", "name"}, extraLabels...))
|
||||
reg.MustRegister(suiteResultSuccess)
|
||||
|
||||
// Mark as initialized
|
||||
metricsInitialized = true
|
||||
}
|
||||
@@ -116,7 +155,7 @@ func InitializePrometheusMetrics(cfg *config.Config, reg prometheus.Registerer)
|
||||
// PublishMetricsForEndpoint publishes metrics for the given endpoint and its result.
|
||||
// These metrics will be exposed at /metrics if the metrics are enabled
|
||||
func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, extraLabels []string) {
|
||||
labelValues := []string{}
|
||||
var labelValues []string
|
||||
for _, label := range extraLabels {
|
||||
if value, ok := ep.ExtraLabels[label]; ok {
|
||||
labelValues = append(labelValues, value)
|
||||
@@ -124,7 +163,6 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
|
||||
labelValues = append(labelValues, "")
|
||||
}
|
||||
}
|
||||
|
||||
endpointType := ep.Type()
|
||||
resultTotal.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType), strconv.FormatBool(result.Success)}, labelValues...)...).Inc()
|
||||
resultDurationSeconds.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(result.Duration.Seconds())
|
||||
@@ -146,3 +184,35 @@ func PublishMetricsForEndpoint(ep *endpoint.Endpoint, result *endpoint.Result, e
|
||||
resultEndpointSuccess.WithLabelValues(append([]string{ep.Key(), ep.Group, ep.Name, string(endpointType)}, labelValues...)...).Set(0)
|
||||
}
|
||||
}
|
||||
|
||||
// PublishMetricsForSuite publishes metrics for the given suite and its result.
|
||||
// These metrics will be exposed at /metrics if the metrics are enabled
|
||||
func PublishMetricsForSuite(s *suite.Suite, result *suite.Result, extraLabels []string) {
|
||||
if !metricsInitialized {
|
||||
return
|
||||
}
|
||||
var labelValues []string
|
||||
// For now, suites don't have ExtraLabels, so we'll use empty values
|
||||
// This maintains consistency with endpoint metrics structure
|
||||
for range extraLabels {
|
||||
labelValues = append(labelValues, "")
|
||||
}
|
||||
// Publish suite execution counter
|
||||
suiteResultTotal.WithLabelValues(
|
||||
append([]string{s.Key(), s.Group, s.Name, strconv.FormatBool(result.Success)}, labelValues...)...,
|
||||
).Inc()
|
||||
// Publish suite duration
|
||||
suiteResultDurationSeconds.WithLabelValues(
|
||||
append([]string{s.Key(), s.Group, s.Name}, labelValues...)...,
|
||||
).Set(result.Duration.Seconds())
|
||||
// Publish suite success status
|
||||
if result.Success {
|
||||
suiteResultSuccess.WithLabelValues(
|
||||
append([]string{s.Key(), s.Group, s.Name}, labelValues...)...,
|
||||
).Set(1)
|
||||
} else {
|
||||
suiteResultSuccess.WithLabelValues(
|
||||
append([]string{s.Key(), s.Group, s.Name}, labelValues...)...,
|
||||
).Set(0)
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint"
|
||||
"github.com/TwiN/gatus/v5/config/endpoint/dns"
|
||||
"github.com/TwiN/gatus/v5/config/suite"
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/testutil"
|
||||
)
|
||||
@@ -226,3 +227,93 @@ gatus_results_endpoint_success{group="http-ep-group",key="http-ep-group_http-ep-
|
||||
t.Errorf("Expected no errors but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishMetricsForSuite(t *testing.T) {
|
||||
reg := prometheus.NewRegistry()
|
||||
InitializePrometheusMetrics(&config.Config{}, reg)
|
||||
|
||||
testSuite := &suite.Suite{
|
||||
Name: "test-suite",
|
||||
Group: "test-group",
|
||||
}
|
||||
// Test successful suite execution
|
||||
successResult := &suite.Result{
|
||||
Success: true,
|
||||
Duration: 5 * time.Second,
|
||||
Name: "test-suite",
|
||||
Group: "test-group",
|
||||
}
|
||||
PublishMetricsForSuite(testSuite, successResult, []string{})
|
||||
|
||||
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||
# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds
|
||||
# TYPE gatus_suite_results_duration_seconds gauge
|
||||
gatus_suite_results_duration_seconds{group="test-group",key="test-group_test-suite",name="test-suite"} 5
|
||||
# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)
|
||||
# TYPE gatus_suite_results_success gauge
|
||||
gatus_suite_results_success{group="test-group",key="test-group_test-suite",name="test-suite"} 1
|
||||
# HELP gatus_suite_results_total Total number of suite executions
|
||||
# TYPE gatus_suite_results_total counter
|
||||
gatus_suite_results_total{group="test-group",key="test-group_test-suite",name="test-suite",success="true"} 1
|
||||
`), "gatus_suite_results_duration_seconds", "gatus_suite_results_success", "gatus_suite_results_total")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no errors but got: %v", err)
|
||||
}
|
||||
|
||||
// Test failed suite execution
|
||||
failureResult := &suite.Result{
|
||||
Success: false,
|
||||
Duration: 10 * time.Second,
|
||||
Name: "test-suite",
|
||||
Group: "test-group",
|
||||
}
|
||||
PublishMetricsForSuite(testSuite, failureResult, []string{})
|
||||
|
||||
err = testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||
# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds
|
||||
# TYPE gatus_suite_results_duration_seconds gauge
|
||||
gatus_suite_results_duration_seconds{group="test-group",key="test-group_test-suite",name="test-suite"} 10
|
||||
# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)
|
||||
# TYPE gatus_suite_results_success gauge
|
||||
gatus_suite_results_success{group="test-group",key="test-group_test-suite",name="test-suite"} 0
|
||||
# HELP gatus_suite_results_total Total number of suite executions
|
||||
# TYPE gatus_suite_results_total counter
|
||||
gatus_suite_results_total{group="test-group",key="test-group_test-suite",name="test-suite",success="false"} 1
|
||||
gatus_suite_results_total{group="test-group",key="test-group_test-suite",name="test-suite",success="true"} 1
|
||||
`), "gatus_suite_results_duration_seconds", "gatus_suite_results_success", "gatus_suite_results_total")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no errors but got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublishMetricsForSuite_NoGroup(t *testing.T) {
|
||||
reg := prometheus.NewRegistry()
|
||||
InitializePrometheusMetrics(&config.Config{}, reg)
|
||||
|
||||
testSuite := &suite.Suite{
|
||||
Name: "no-group-suite",
|
||||
Group: "",
|
||||
}
|
||||
result := &suite.Result{
|
||||
Success: true,
|
||||
Duration: 3 * time.Second,
|
||||
Name: "no-group-suite",
|
||||
Group: "",
|
||||
}
|
||||
PublishMetricsForSuite(testSuite, result, []string{})
|
||||
|
||||
err := testutil.GatherAndCompare(reg, bytes.NewBufferString(`
|
||||
# HELP gatus_suite_results_duration_seconds Duration of suite execution in seconds
|
||||
# TYPE gatus_suite_results_duration_seconds gauge
|
||||
gatus_suite_results_duration_seconds{group="",key="_no-group-suite",name="no-group-suite"} 3
|
||||
# HELP gatus_suite_results_success Whether the suite execution was successful (1) or not (0)
|
||||
# TYPE gatus_suite_results_success gauge
|
||||
gatus_suite_results_success{group="",key="_no-group-suite",name="no-group-suite"} 1
|
||||
# HELP gatus_suite_results_total Total number of suite executions
|
||||
# TYPE gatus_suite_results_total counter
|
||||
gatus_suite_results_total{group="",key="_no-group-suite",name="no-group-suite",success="true"} 1
|
||||
`), "gatus_suite_results_duration_seconds", "gatus_suite_results_success", "gatus_suite_results_total")
|
||||
if err != nil {
|
||||
t.Errorf("Expected no errors but got: %v", err)
|
||||
}
|
||||
}
|
||||
+3
-3
@@ -26,9 +26,9 @@ type Config struct {
|
||||
gate *g8.Gate
|
||||
}
|
||||
|
||||
// IsValid returns whether the security configuration is valid or not
|
||||
func (c *Config) IsValid() bool {
|
||||
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.isValid())
|
||||
// ValidateAndSetDefaults returns whether the security configuration is valid or not and sets default values.
|
||||
func (c *Config) ValidateAndSetDefaults() bool {
|
||||
return (c.Basic != nil && c.Basic.isValid()) || (c.OIDC != nil && c.OIDC.ValidateAndSetDefaults())
|
||||
}
|
||||
|
||||
// RegisterHandlers registers all handlers required based on the security configuration
|
||||
|
||||
@@ -9,12 +9,12 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
func TestConfig_IsValid(t *testing.T) {
|
||||
func TestConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
c := &Config{
|
||||
Basic: nil,
|
||||
OIDC: nil,
|
||||
}
|
||||
if c.IsValid() {
|
||||
if c.ValidateAndSetDefaults() {
|
||||
t.Error("expected empty config to be valid")
|
||||
}
|
||||
}
|
||||
@@ -65,6 +65,7 @@ func TestConfig_ApplySecurityMiddleware(t *testing.T) {
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
SessionTTL: DefaultOIDCSessionTTL,
|
||||
oauth2Config: oauth2.Config{},
|
||||
verifier: nil,
|
||||
}}
|
||||
|
||||
+18
-10
@@ -13,21 +13,29 @@ import (
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultOIDCSessionTTL = 8 * time.Hour
|
||||
)
|
||||
|
||||
// OIDCConfig is the configuration for OIDC authentication
|
||||
type OIDCConfig struct {
|
||||
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
||||
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
||||
IssuerURL string `yaml:"issuer-url"` // e.g. https://dev-12345678.okta.com
|
||||
RedirectURL string `yaml:"redirect-url"` // e.g. http://localhost:8080/authorization-code/callback
|
||||
ClientID string `yaml:"client-id"`
|
||||
ClientSecret string `yaml:"client-secret"`
|
||||
Scopes []string `yaml:"scopes"` // e.g. ["openid"]
|
||||
AllowedSubjects []string `yaml:"allowed-subjects"` // e.g. ["user1@example.com"]. If empty, all subjects are allowed
|
||||
SessionTTL time.Duration `yaml:"session-ttl"` // e.g. 8h. Defaults to 8 hours
|
||||
|
||||
oauth2Config oauth2.Config
|
||||
verifier *oidc.IDTokenVerifier
|
||||
}
|
||||
|
||||
// isValid returns whether the basic security configuration is valid or not
|
||||
func (c *OIDCConfig) isValid() bool {
|
||||
// ValidateAndSetDefaults returns whether the OIDC configuration is valid and sets default values.
|
||||
func (c *OIDCConfig) ValidateAndSetDefaults() bool {
|
||||
if c.SessionTTL <= 0 {
|
||||
c.SessionTTL = DefaultOIDCSessionTTL
|
||||
}
|
||||
return len(c.IssuerURL) > 0 && len(c.RedirectURL) > 0 && strings.HasSuffix(c.RedirectURL, "/authorization-code/callback") && len(c.ClientID) > 0 && len(c.ClientSecret) > 0 && len(c.Scopes) > 0
|
||||
}
|
||||
|
||||
@@ -131,12 +139,12 @@ func (c *OIDCConfig) callbackHandler(w http.ResponseWriter, r *http.Request) { /
|
||||
func (c *OIDCConfig) setSessionCookie(w http.ResponseWriter, idToken *oidc.IDToken) {
|
||||
// At this point, the user has been confirmed. All that's left to do is create a session.
|
||||
sessionID := uuid.NewString()
|
||||
sessions.SetWithTTL(sessionID, idToken.Subject, time.Hour)
|
||||
sessions.SetWithTTL(sessionID, idToken.Subject, c.SessionTTL)
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: cookieNameSession,
|
||||
Value: sessionID,
|
||||
Path: "/",
|
||||
MaxAge: int(time.Hour.Seconds()),
|
||||
MaxAge: int(c.SessionTTL.Seconds()),
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
+22
-2
@@ -4,11 +4,12 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/coreos/go-oidc/v3/oidc"
|
||||
)
|
||||
|
||||
func TestOIDCConfig_isValid(t *testing.T) {
|
||||
func TestOIDCConfig_ValidateAndSetDefaults(t *testing.T) {
|
||||
c := &OIDCConfig{
|
||||
IssuerURL: "https://sso.gatus.io/",
|
||||
RedirectURL: "http://localhost:80/authorization-code/callback",
|
||||
@@ -16,10 +17,14 @@ func TestOIDCConfig_isValid(t *testing.T) {
|
||||
ClientSecret: "client-secret",
|
||||
Scopes: []string{"openid"},
|
||||
AllowedSubjects: []string{"user1@example.com"},
|
||||
SessionTTL: 0, // Not set! ValidateAndSetDefaults should set it to DefaultOIDCSessionTTL
|
||||
}
|
||||
if !c.isValid() {
|
||||
if !c.ValidateAndSetDefaults() {
|
||||
t.Error("OIDCConfig should be valid")
|
||||
}
|
||||
if c.SessionTTL != DefaultOIDCSessionTTL {
|
||||
t.Error("expected SessionTTL to be set to DefaultOIDCSessionTTL")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConfig_callbackHandler(t *testing.T) {
|
||||
@@ -68,3 +73,18 @@ func TestOIDCConfig_setSessionCookie(t *testing.T) {
|
||||
t.Error("expected cookie to be set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestOIDCConfig_setSessionCookieWithCustomTTL(t *testing.T) {
|
||||
customTTL := 30 * time.Minute
|
||||
c := &OIDCConfig{SessionTTL: customTTL}
|
||||
responseRecorder := httptest.NewRecorder()
|
||||
c.setSessionCookie(responseRecorder, &oidc.IDToken{Subject: "test@example.com"})
|
||||
cookies := responseRecorder.Result().Cookies()
|
||||
if len(cookies) == 0 {
|
||||
t.Error("expected cookie to be set")
|
||||
}
|
||||
sessionCookie := cookies[0]
|
||||
if sessionCookie.MaxAge != int(customTTL.Seconds()) {
|
||||
t.Errorf("expected cookie MaxAge to be %d, but was %d", int(customTTL.Seconds()), sessionCookie.MaxAge)
|
||||
}
|
||||
}
|
||||
@@ -4,5 +4,6 @@ import "errors"
|
||||
|
||||
var (
|
||||
ErrEndpointNotFound = errors.New("endpoint not found") // When an endpoint does not exist in the store
|
||||
ErrSuiteNotFound = errors.New("suite not found") // When a suite does not exist in the store
|
||||
ErrInvalidTimeRange = errors.New("'from' cannot be older than 'to'") // When an invalid time range is provided
|
||||
)
|
||||
File renamed without changes.
File renamed without changes.
Loaded 100 of 136 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user