Compare commits
73 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 | ||
|
|
e1edc15337 | ||
|
|
d17f51a1a1 | ||
|
|
a9fb48b26c | ||
|
|
131447f702 | ||
|
|
609a634df3 | ||
|
|
6c28de6950 | ||
|
|
440b732c71 | ||
|
|
8d63462fcd | ||
|
|
daf67dc1e6 | ||
|
|
3ebed01b4c | ||
|
|
a2f5516b06 | ||
|
|
a68e7e39bd | ||
|
|
f9d7320a2a | ||
|
|
c374649019 | ||
|
|
f6e938746f | ||
|
|
2c6fede468 | ||
|
|
9205cb2890 | ||
|
|
6a9cbb1728 | ||
|
|
4667fdbc15 |
BIN
.github/assets/dashboard-conditions.jpg
vendored
Normal file
|
After Width: | Height: | Size: 290 KiB |
BIN
.github/assets/dashboard-conditions.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
BIN
.github/assets/dashboard-dark.jpg
vendored
Normal file
|
After Width: | Height: | Size: 536 KiB |
BIN
.github/assets/dashboard-dark.png
vendored
|
Before Width: | Height: | Size: 90 KiB |
BIN
.github/assets/endpoint-groups.jpg
vendored
Normal file
|
After Width: | Height: | Size: 375 KiB |
BIN
.github/assets/endpoint-groups.png
vendored
|
Before Width: | Height: | Size: 39 KiB |
BIN
.github/assets/example.jpg
vendored
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
.github/assets/example.png
vendored
|
Before Width: | Height: | Size: 43 KiB |
5
.github/codecov.yml
vendored
@@ -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:
|
||||
|
||||
6
.github/workflows/benchmark.yml
vendored
@@ -20,11 +20,11 @@ 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@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Benchmark
|
||||
run: go test -bench=. ./storage/store
|
||||
|
||||
6
.github/workflows/labeler.yml
vendored
@@ -26,10 +26,15 @@ jobs:
|
||||
gh issue edit "$NUMBER" --add-label "feature"
|
||||
elif [[ $TITLE == "fix"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "bug"
|
||||
elif [[ $TITLE == "docs"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "documentation"
|
||||
fi
|
||||
if [[ $TITLE == *"alerting"* || $TITLE == *"provider"* || $TITLE == *"alert"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/alerting"
|
||||
fi
|
||||
if [[ $TITLE == *"(ui)"* || $TITLE == *"ui:"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/ui"
|
||||
fi
|
||||
if [[ $TITLE == *"storage"* || $TITLE == *"postgres"* || $TITLE == *"sqlite"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/storage"
|
||||
fi
|
||||
@@ -39,4 +44,3 @@ jobs:
|
||||
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
|
||||
gh issue edit "$NUMBER" --add-label "area/metrics"
|
||||
fi
|
||||
|
||||
|
||||
2
.github/workflows/publish-custom.yml
vendored
@@ -10,7 +10,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
2
.github/workflows/publish-experimental.yml
vendored
@@ -5,7 +5,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
4
.github/workflows/publish-latest.yml
vendored
@@ -11,9 +11,9 @@ jobs:
|
||||
publish-latest:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
2
.github/workflows/publish-release.yml
vendored
@@ -8,7 +8,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 240
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
- name: Set up Docker Buildx
|
||||
|
||||
2
.github/workflows/test-ui.yml
vendored
@@ -13,6 +13,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v5
|
||||
- run: make frontend-install-dependencies
|
||||
- run: make frontend-build
|
||||
8
.github/workflows/test.yml
vendored
@@ -16,10 +16,10 @@ 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
|
||||
- uses: actions/checkout@v4
|
||||
go-version: 1.24.4
|
||||
- uses: actions/checkout@v5
|
||||
- name: Build binary to make sure it works
|
||||
run: go build
|
||||
- name: Test
|
||||
@@ -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 }}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/logr"
|
||||
"gopkg.in/yaml.v3"
|
||||
@@ -14,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
|
||||
@@ -35,6 +38,9 @@ type Alert struct {
|
||||
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
|
||||
SuccessThreshold int `yaml:"success-threshold"`
|
||||
|
||||
// MinimumReminderInterval is the interval between reminders
|
||||
MinimumReminderInterval time.Duration `yaml:"minimum-reminder-interval,omitempty"`
|
||||
|
||||
// Description of the alert. Will be included in the alert sent.
|
||||
//
|
||||
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
|
||||
@@ -74,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) {
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
214
alerting/provider/datadog/datadog.go
Normal file
@@ -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
|
||||
}
|
||||
183
alerting/provider/datadog/datadog_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -166,7 +166,14 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
|
||||
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
|
||||
description = "\n\nAlert description: " + alertDescription
|
||||
}
|
||||
return subject, message + description + formattedConditionResults
|
||||
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
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
Provider AlertProvider
|
||||
Alert alert.Alert
|
||||
Resolved bool
|
||||
Endpoint *endpoint.Endpoint
|
||||
ExpectedSubject string
|
||||
ExpectedBody string
|
||||
}{
|
||||
@@ -84,6 +85,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
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",
|
||||
},
|
||||
@@ -92,14 +94,42 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
|
||||
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(
|
||||
&endpoint.Endpoint{Name: "endpoint-name"},
|
||||
scenario.Endpoint,
|
||||
&scenario.Alert,
|
||||
&endpoint.Result{
|
||||
ConditionResults: []*endpoint.ConditionResult{
|
||||
|
||||
187
alerting/provider/ifttt/ifttt.go
Normal file
@@ -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
|
||||
}
|
||||
154
alerting/provider/ifttt/ifttt_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -174,21 +174,21 @@ func TestAlertProvider_BuildRequestBody(t *testing.T) {
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":3,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
|
||||
},
|
||||
{
|
||||
Name: "resolved",
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}},
|
||||
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 4, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: true,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":4,"MinimumReminderInterval":0,"Description":"description-1","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"resolved","title":"endpoint-name","details":"description-1","condition_results":[{"condition":"[CONNECTED] == true","success":true},{"condition":"[STATUS] == 200","success":true}],"url":""}`,
|
||||
},
|
||||
{
|
||||
Name: "group-override",
|
||||
Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "some-integration-key"}, Overrides: []Override{{Group: "g", Config: Config{IntegrationKey: "different-integration-key"}}}},
|
||||
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
|
||||
Resolved: false,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
|
||||
ExpectedBody: `{"alert":{"Type":"ilert","Enabled":null,"FailureThreshold":3,"SuccessThreshold":5,"MinimumReminderInterval":0,"Description":"description-2","SendOnResolved":true,"ProviderOverride":null,"ResolveKey":"123","Triggered":false},"name":"endpoint-name","group":"","status":"firing","title":"endpoint-name","details":"description-2","condition_results":[{"condition":"[CONNECTED] == true","success":false},{"condition":"[STATUS] == 200","success":false}],"url":""}`,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
193
alerting/provider/line/line.go
Normal file
@@ -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
|
||||
}
|
||||
147
alerting/provider/line/line_test.go
Normal file
@@ -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)
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import (
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/TwiN/gatus/v5/alerting/alert"
|
||||
"github.com/TwiN/gatus/v5/client"
|
||||
@@ -203,7 +202,6 @@ func randStringBytes(n int) string {
|
||||
// All the compatible characters to use in a transaction ID
|
||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
rand.Seed(time.Now().UnixNano())
|
||||
for i := range b {
|
||||
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
|
||||
}
|
||||
|
||||
215
alerting/provider/newrelic/newrelic.go
Normal file
@@ -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
|
||||
}
|
||||
189
alerting/provider/newrelic/newrelic_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
183
alerting/provider/plivo/plivo.go
Normal file
@@ -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
|
||||
}
|
||||
514
alerting/provider/plivo/plivo_test.go
Normal file
@@ -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
|
||||
}
|
||||
|
||||
212
alerting/provider/rocketchat/rocketchat.go
Normal file
@@ -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
|
||||
}
|
||||
164
alerting/provider/rocketchat/rocketchat_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
248
alerting/provider/sendgrid/sendgrid.go
Normal file
@@ -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
|
||||
}
|
||||
517
alerting/provider/sendgrid/sendgrid_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
196
alerting/provider/signal/signal.go
Normal file
@@ -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
|
||||
}
|
||||
151
alerting/provider/signal/signal_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
184
alerting/provider/signl4/signl4.go
Normal file
@@ -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
|
||||
}
|
||||
392
alerting/provider/signl4/signl4_test.go
Normal file
@@ -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{
|
||||
|
||||
220
alerting/provider/splunk/splunk.go
Normal file
@@ -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
|
||||
}
|
||||
155
alerting/provider/splunk/splunk_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
190
alerting/provider/squadcast/squadcast.go
Normal file
@@ -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
|
||||
}
|
||||
141
alerting/provider/squadcast/squadcast_test.go
Normal file
@@ -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) {
|
||||
|
||||
212
alerting/provider/vonage/vonage.go
Normal file
@@ -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
|
||||
}
|
||||
546
alerting/provider/vonage/vonage_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
171
alerting/provider/webex/webex.go
Normal file
@@ -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
|
||||
}
|
||||
134
alerting/provider/webex/webex_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
197
alerting/provider/zapier/zapier.go
Normal file
@@ -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
|
||||
}
|
||||
162
alerting/provider/zapier/zapier_test.go
Normal file
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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{
|
||||
@@ -75,7 +76,7 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
|
||||
// UNPROTECTED ROUTES //
|
||||
////////////////////////
|
||||
unprotectedAPIRouter := apiRouter.Group("/")
|
||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security}.GetConfig)
|
||||
unprotectedAPIRouter.Get("/v1/config", ConfigHandler{securityConfig: cfg.Security, config: cfg}.GetConfig)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.svg", HealthBadge)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
|
||||
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/TwiN/gatus/v5/config"
|
||||
"github.com/TwiN/gatus/v5/security"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
type ConfigHandler struct {
|
||||
securityConfig *security.Config
|
||||
config *config.Config
|
||||
}
|
||||
|
||||
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
@@ -18,8 +21,24 @@ func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error {
|
||||
hasOIDC = handler.securityConfig.OIDC != nil
|
||||
isAuthenticated = handler.securityConfig.IsAuthenticated(c)
|
||||
}
|
||||
// Return the config
|
||||
|
||||
// Prepare response with announcements
|
||||
response := map[string]interface{}{
|
||||
"oidc": hasOIDC,
|
||||
"authenticated": isAuthenticated,
|
||||
}
|
||||
// Add announcements if available, otherwise use empty slice
|
||||
if handler.config != nil && handler.config.Announcements != nil && len(handler.config.Announcements) > 0 {
|
||||
response["announcements"] = handler.config.Announcements
|
||||
} else {
|
||||
response["announcements"] = []interface{}{}
|
||||
}
|
||||
|
||||
// Return the config as JSON
|
||||
c.Set("Content-Type", "application/json")
|
||||
return c.Status(200).
|
||||
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated))
|
||||
responseBytes, err := json.Marshal(response)
|
||||
if err != nil {
|
||||
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
|
||||
}
|
||||
return c.Status(200).Send(responseBytes)
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Error("expected err to be nil, but was", err)
|
||||
}
|
||||
if string(body) != `{"oidc":true,"authenticated":false}` {
|
||||
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body))
|
||||
if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
|
||||
t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
)
|
||||
|
||||
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
extraLabels := cfg.GetUniqueExtraMetricLabels()
|
||||
return func(c *fiber.Ctx) error {
|
||||
// Check if the success query parameter is present
|
||||
success, exists := c.Queries()["success"]
|
||||
@@ -55,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())
|
||||
}
|
||||
@@ -74,7 +75,7 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
|
||||
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
|
||||
}
|
||||
if cfg.Metrics {
|
||||
metrics.PublishMetricsForEndpoint(convertedEndpoint, result)
|
||||
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
|
||||
}
|
||||
// Return the result
|
||||
return c.Status(200).SendString("")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
59
api/suite_status.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
519
api/suite_status_test.go
Normal file
@@ -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
|
||||
}())))
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
102
client/client.go
@@ -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
|
||||
}
|
||||
|
||||
@@ -39,28 +39,42 @@ 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("example.com"); err != nil {
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Fatalf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
}
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
}
|
||||
// Hack to pretend like the domain is expiring in 1 hour, which should trigger a refresh
|
||||
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(time.Hour), 25*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(time.Hour), 25*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
}
|
||||
// Make sure the refresh works when the ttl is <24 hours
|
||||
whoisExpirationDateCache.SetWithTTL("example.com", time.Now().Add(35*time.Hour), 23*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("example.com"); err != nil {
|
||||
whoisExpirationDateCache.SetWithTTL("gatus.io", time.Now().Add(35*time.Hour), 23*time.Hour)
|
||||
if domainExpiration, err := GetDomainExpiration("gatus.io"); err != nil {
|
||||
t.Errorf("expected error to be nil, but got: `%s`", err)
|
||||
} else if domainExpiration <= 0 {
|
||||
t.Error("expected domain expiration to be higher than 0")
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -37,7 +37,7 @@ endpoints:
|
||||
query-name: "example.com"
|
||||
query-type: "A"
|
||||
conditions:
|
||||
- "[BODY] == 93.184.215.14"
|
||||
- "[BODY] == pat(*.*.*.*)" # Matches any IPv4 address
|
||||
- "[DNS_RCODE] == NOERROR"
|
||||
|
||||
- name: icmp-ping
|
||||
|
||||
94
config/announcement/announcement.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package announcement
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// TypeOutage represents a service outage
|
||||
TypeOutage = "outage"
|
||||
|
||||
// TypeWarning represents a warning or potential issue
|
||||
TypeWarning = "warning"
|
||||
|
||||
// TypeInformation represents general information
|
||||
TypeInformation = "information"
|
||||
|
||||
// TypeOperational represents operational status or resolved issues
|
||||
TypeOperational = "operational"
|
||||
|
||||
// TypeNone represents no specific type (default)
|
||||
TypeNone = "none"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrInvalidAnnouncementType is returned when an invalid announcement type is specified
|
||||
ErrInvalidAnnouncementType = errors.New("invalid announcement type")
|
||||
|
||||
// ErrEmptyMessage is returned when an announcement has an empty message
|
||||
ErrEmptyMessage = errors.New("announcement message cannot be empty")
|
||||
|
||||
// ErrMissingTimestamp is returned when an announcement has an empty timestamp
|
||||
ErrMissingTimestamp = errors.New("announcement timestamp must be set")
|
||||
|
||||
// validTypes contains all valid announcement types
|
||||
validTypes = map[string]bool{
|
||||
TypeOutage: true,
|
||||
TypeWarning: true,
|
||||
TypeInformation: true,
|
||||
TypeOperational: true,
|
||||
TypeNone: true,
|
||||
}
|
||||
)
|
||||
|
||||
// Announcement represents a system-wide announcement
|
||||
type Announcement struct {
|
||||
// Timestamp is the UTC timestamp when the announcement was made
|
||||
Timestamp time.Time `yaml:"timestamp" json:"timestamp"`
|
||||
|
||||
// Type is the type of announcement (outage, warning, information, operational, none)
|
||||
Type string `yaml:"type" json:"type"`
|
||||
|
||||
// Message is the user-facing text describing the announcement
|
||||
Message string `yaml:"message" json:"message"`
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates the announcement and sets default values if necessary
|
||||
func (a *Announcement) ValidateAndSetDefaults() error {
|
||||
// Validate message
|
||||
if a.Message == "" {
|
||||
return ErrEmptyMessage
|
||||
}
|
||||
// Set default type if empty
|
||||
if a.Type == "" {
|
||||
a.Type = TypeNone
|
||||
}
|
||||
// Validate type
|
||||
if !validTypes[a.Type] {
|
||||
return ErrInvalidAnnouncementType
|
||||
}
|
||||
// If timestamp is zero, return an error
|
||||
if a.Timestamp.IsZero() {
|
||||
return ErrMissingTimestamp
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SortByTimestamp sorts a slice of announcements by timestamp in descending order (newest first)
|
||||
func SortByTimestamp(announcements []*Announcement) {
|
||||
sort.Slice(announcements, func(i, j int) bool {
|
||||
return announcements[i].Timestamp.After(announcements[j].Timestamp)
|
||||
})
|
||||
}
|
||||
|
||||
// ValidateAndSetDefaults validates a slice of announcements and sets defaults
|
||||
func ValidateAndSetDefaults(announcements []*Announcement) error {
|
||||
for _, announcement := range announcements {
|
||||
if err := announcement.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
299
config/config.go
@@ -6,6 +6,7 @@ import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -13,16 +14,20 @@ 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"
|
||||
"github.com/TwiN/gatus/v5/storage"
|
||||
"github.com/TwiN/logr"
|
||||
"github.com/gofiber/fiber/v2/log"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
@@ -34,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")
|
||||
@@ -66,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"`
|
||||
|
||||
@@ -80,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"`
|
||||
|
||||
@@ -99,10 +116,38 @@ 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"`
|
||||
|
||||
configPath string // path to the file or directory from which config was loaded
|
||||
lastFileModTime time.Time // last modification time
|
||||
}
|
||||
|
||||
// GetUniqueExtraMetricLabels returns a slice of unique metric labels from all enabled endpoints
|
||||
// in the configuration. It iterates through each endpoint, checks if it is enabled,
|
||||
// and then collects unique labels from the endpoint's labels map.
|
||||
func (config *Config) GetUniqueExtraMetricLabels() []string {
|
||||
labels := make([]string, 0)
|
||||
for _, ep := range config.Endpoints {
|
||||
if !ep.IsEnabled() {
|
||||
continue
|
||||
}
|
||||
for label := range ep.ExtraLabels {
|
||||
if contains(labels, label) {
|
||||
continue
|
||||
}
|
||||
labels = append(labels, label)
|
||||
}
|
||||
}
|
||||
if len(labels) > 1 {
|
||||
sort.Strings(labels)
|
||||
}
|
||||
return labels
|
||||
}
|
||||
|
||||
func (config *Config) GetEndpointByKey(key string) *endpoint.Endpoint {
|
||||
for i := 0; i < len(config.Endpoints); i++ {
|
||||
ep := config.Endpoints[i]
|
||||
@@ -246,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 {
|
||||
@@ -255,45 +300,122 @@ 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 := 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 validateRemoteConfig(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
|
||||
}
|
||||
// Sort announcements by timestamp (newest first) for API response
|
||||
announcement.SortByTimestamp(config.Announcements)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ValidateRemoteConfig(config *Config) error {
|
||||
if config.Remote != nil {
|
||||
if err := config.Remote.ValidateAndSetDefaults(); err != nil {
|
||||
return err
|
||||
@@ -302,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,
|
||||
@@ -317,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 {
|
||||
@@ -328,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 {
|
||||
@@ -339,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 {
|
||||
@@ -348,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 {
|
||||
@@ -362,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 {
|
||||
@@ -375,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,
|
||||
@@ -412,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
|
||||
@@ -439,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 {
|
||||
log.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -453,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 {
|
||||
log.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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,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)
|
||||
}
|
||||
@@ -475,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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,13 +138,13 @@ endpoints:
|
||||
pathAndFiles: map[string]string{
|
||||
"config.yaml": "",
|
||||
},
|
||||
expectedError: ErrNoEndpointInConfig,
|
||||
expectedError: ErrNoEndpointOrSuiteInConfig,
|
||||
},
|
||||
{
|
||||
name: "dir-with-two-config-files",
|
||||
configPath: dir,
|
||||
pathAndFiles: map[string]string{
|
||||
"config.yaml": `endpoints:
|
||||
"config.yaml": `endpoints:
|
||||
- name: one
|
||||
url: https://example.com
|
||||
conditions:
|
||||
@@ -135,7 +155,7 @@ endpoints:
|
||||
url: https://example.org
|
||||
conditions:
|
||||
- "len([BODY]) > 0"`,
|
||||
"config.yml": `endpoints:
|
||||
"config.yml": `endpoints:
|
||||
- name: three
|
||||
url: https://twin.sh/health
|
||||
conditions:
|
||||
@@ -237,7 +257,7 @@ endpoints:
|
||||
for _, scenario := range scenarios {
|
||||
t.Run(scenario.name, func(t *testing.T) {
|
||||
for path, content := range scenario.pathAndFiles {
|
||||
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0644); err != nil {
|
||||
if err := os.WriteFile(filepath.Join(dir, path), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("[%s] failed to write file: %v", scenario.name, err)
|
||||
}
|
||||
}
|
||||
@@ -282,7 +302,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
|
||||
url: https://twin.sh/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"
|
||||
`), 0644)
|
||||
`), 0o644)
|
||||
|
||||
t.Run("config-file-as-config-path", func(t *testing.T) {
|
||||
config, err := LoadConfiguration(configFilePath)
|
||||
@@ -298,7 +318,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
|
||||
- name: website
|
||||
url: https://twin.sh/health
|
||||
conditions:
|
||||
- "[STATUS] == 200"`), 0644); err != nil {
|
||||
- "[STATUS] == 200"`), 0o644); err != nil {
|
||||
t.Fatalf("failed to overwrite config file: %v", err)
|
||||
}
|
||||
if !config.HasLoadedConfigurationBeenModified() {
|
||||
@@ -315,7 +335,7 @@ func TestConfig_HasLoadedConfigurationBeenModified(t *testing.T) {
|
||||
}
|
||||
time.Sleep(time.Second) // Because the file mod time only has second precision, we have to wait for a second
|
||||
// Update the config file
|
||||
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0644); err != nil {
|
||||
if err = os.WriteFile(filepath.Join(dir, "metrics.yaml"), []byte(`metrics: true`), 0o644); err != nil {
|
||||
t.Fatalf("failed to overwrite config file: %v", err)
|
||||
}
|
||||
if !config.HasLoadedConfigurationBeenModified() {
|
||||
@@ -713,15 +733,15 @@ func TestParseAndValidateBadConfigBytes(t *testing.T) {
|
||||
_, err := parseAndValidateConfigBytes([]byte(`
|
||||
badconfig:
|
||||
- asdsa: w0w
|
||||
usadasdrl: asdxzczxc
|
||||
usadasdrl: asdxzczxc
|
||||
asdas:
|
||||
- soup
|
||||
`))
|
||||
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 {
|
||||
@@ -1943,3 +1997,683 @@ func TestGetAlertingProviderByAlertType(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfig_GetUniqueExtraMetricLabels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *Config
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "no-endpoints",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{},
|
||||
},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single-endpoint-no-labels",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{},
|
||||
},
|
||||
{
|
||||
name: "single-endpoint-with-labels",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.com",
|
||||
Enabled: toPtr(true),
|
||||
ExtraLabels: map[string]string{
|
||||
"env": "production",
|
||||
"team": "backend",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{"env", "team"},
|
||||
},
|
||||
{
|
||||
name: "multiple-endpoints-with-labels",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.com",
|
||||
Enabled: toPtr(true),
|
||||
ExtraLabels: map[string]string{
|
||||
"env": "production",
|
||||
"team": "backend",
|
||||
"module": "auth",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "endpoint2",
|
||||
URL: "https://example.org",
|
||||
Enabled: toPtr(true),
|
||||
ExtraLabels: map[string]string{
|
||||
"env": "staging",
|
||||
"team": "frontend",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{"env", "team", "module"},
|
||||
},
|
||||
{
|
||||
name: "multiple-endpoints-with-some-disabled",
|
||||
config: &Config{
|
||||
Endpoints: []*endpoint.Endpoint{
|
||||
{
|
||||
Name: "endpoint1",
|
||||
URL: "https://example.com",
|
||||
Enabled: toPtr(true),
|
||||
ExtraLabels: map[string]string{
|
||||
"env": "production",
|
||||
"team": "backend",
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "endpoint2",
|
||||
URL: "https://example.org",
|
||||
Enabled: toPtr(false),
|
||||
ExtraLabels: map[string]string{
|
||||
"module": "auth",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expected: []string{"env", "team"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
labels := tt.config.GetUniqueExtraMetricLabels()
|
||||
if len(labels) != len(tt.expected) {
|
||||
t.Errorf("expected %d labels, got %d", len(tt.expected), len(labels))
|
||||
}
|
||||
for _, label := range tt.expected {
|
||||
if !contains(labels, label) {
|
||||
t.Errorf("expected label %s to be present", label)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
@@ -99,6 +101,9 @@ type Endpoint struct {
|
||||
// Headers of the request
|
||||
Headers map[string]string `yaml:"headers,omitempty"`
|
||||
|
||||
// ExtraLabels are key-value pairs that can be used to metric the endpoint
|
||||
ExtraLabels map[string]string `yaml:"extra-labels,omitempty"`
|
||||
|
||||
// Interval is the duration to wait between every status check
|
||||
Interval time.Duration `yaml:"interval,omitempty"`
|
||||
|
||||
@@ -128,6 +133,21 @@ type Endpoint struct {
|
||||
|
||||
// NumberOfSuccessesInARow is the number of successful evaluations in a row
|
||||
NumberOfSuccessesInARow int `yaml:"-"`
|
||||
|
||||
// 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
|
||||
@@ -249,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
|
||||
@@ -263,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 {
|
||||
@@ -281,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())
|
||||
@@ -293,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)
|
||||
@@ -338,6 +417,9 @@ func (e *Endpoint) getParsedBody() string {
|
||||
if err == nil {
|
||||
body = randRegex.ReplaceAllStringFunc(body, func(match string) string {
|
||||
n, _ := strconv.Atoi(match[15 : len(match)-1])
|
||||
if n > 8192 {
|
||||
n = 8192 // Limit the length of the random string to 8192 bytes to avoid excessive memory usage
|
||||
}
|
||||
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, n)
|
||||
for i := range b {
|
||||
@@ -417,8 +499,7 @@ func (e *Endpoint) call(result *Result) {
|
||||
} else if endpointType == TypeSSH {
|
||||
// If there's no username/password specified, attempt to validate just the SSH banner
|
||||
if len(e.SSHConfig.Username) == 0 && len(e.SSHConfig.Password) == 0 {
|
||||
result.Connected, result.HTTPStatus, err =
|
||||
client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||
result.Connected, result.HTTPStatus, err = client.CheckSSHBanner(strings.TrimPrefix(e.URL, "ssh://"), e.ClientConfig)
|
||||
if err != nil {
|
||||
result.AddError(err.Error())
|
||||
return
|
||||
@@ -484,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")
|
||||
}
|
||||
}
|
||||
273
config/endpoint/placeholder.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
125
config/endpoint/placeholder_test.go
Normal file
@@ -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(),
|
||||
|
||||
121
config/gontext/gontext.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
448
config/gontext/gontext_test.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
11
config/key/key_bench_test.go
Normal file
@@ -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)
|
||||
}
|
||||