Compare commits

...

73 Commits

Author SHA1 Message Date
TwiN
c7f0a32135 fix(tunneling): Adjust exponential backoff duration 2025-09-30 14:27:38 -04:00
TwiN
405c15f756 fix(tunneling): Add exponential backoff retry (#1303) 2025-09-30 14:08:56 -04:00
TwiN
6f1312dfcf chore: Tweak configuration validation and yaml output (#1302) 2025-09-30 13:38:17 -04:00
TwiN
bd296c75da chore: Export validation function (#1301) 2025-09-29 23:01:27 -04:00
TwiN
f007725140 fix(ui): Make sure EndpointCard aligns even if no group + hide-hostname (#1300) 2025-09-29 22:55:11 -04:00
TwiN
40345a03d3 feat(client): Add support for SSH tunneling (#1298)
* feat(client): Add support for SSH tunneling

* Fix test
2025-09-28 14:26:12 -04:00
Rahul Chordiya
97a2be3504 fix(alerting): Added description block in teams-workflows (#1275)
* fix(alerting): Added description block in teams-workflows

* Update teamsworkflows_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-09-25 16:28:22 -04:00
TwiN
15a4133502 fix(alerting): Limit minimum-reminder-interval to >5m (#1290) 2025-09-25 16:24:15 -04:00
Ron
64a5043655 docs(alerting): Remove SIGNL4 untested warning (#1289)
Update README.md

SIGNL4 warning removed. I have tested it and both, triggering and resolving of alerts work fine.
2025-09-24 06:33:57 -04:00
TwiN
5a06a74cc3 fix(events): Retrieve newest events instead of oldest events (#1283)
Fixes #1040
2025-09-21 15:40:17 -04:00
TwiN
d6fa2c955b fix(suites): Handle invalid paths in store and update needsToReadBody to check store (#1282)
* fix(suites): Invalid path in store parameter should return an error

* Refactor

* fix(suites): Update needsToReadBody to check store mappings for body placeholders
2025-09-21 13:15:59 -04:00
mehdiMj
e6576e9080 fix(alerting): Support custom slack title (#1079) 2025-09-20 20:21:46 -04:00
TwiN
cd10b31ab5 fix(condition): Properly format conditions with invalid context placeholders (#1281) 2025-09-20 19:28:27 -04:00
dependabot[bot]
d1ef0b72a4 chore(deps): bump golang.org/x/sync from 0.16.0 to 0.17.0 (#1269)
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.16.0 to 0.17.0.
- [Commits](https://github.com/golang/sync/compare/v0.16.0...v0.17.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.17.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-20 12:22:13 -04:00
TwiN
327a39964d fix(security): Make OIDC session TTL configurable (#1280)
* fix(security): Increase session cookie from 1h to 8h

* fix(security): Make OIDC session TTL configurable

* revert accidental change
2025-09-20 07:29:25 -04:00
TwiN
c87c651ff0 fix(suite): Display condition results when user clicks step in execution flow (#1278) 2025-09-19 12:43:43 -04:00
TwiN
1658825525 fix(suite): Add hyphen support for context keys (#1277) 2025-09-19 12:09:18 -04:00
TwiN
3a95e32210 fix: Suite endpoint listed as standalone endpoint (#1276) 2025-09-19 11:55:58 -04:00
TwiN
bd793305e9 fix(storage): Zero allocation issue with fiber (#1273)
* fix(storage): Zero allocation issue with fiber

* ci: Bump Go version
2025-09-19 11:38:46 -04:00
TwiN
0d2a55cf11 docs: Add gatus-cli command to push a external endpoint status 2025-09-18 07:31:32 -04:00
dependabot[bot]
565831aa46 chore(deps): bump codecov/codecov-action from 5.5.0 to 5.5.1 (#1247)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.5.0 to 5.5.1.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.5.0...v5.5.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-17 17:27:03 -04:00
TwiN
8238a42a55 Revert "fix(storage): resolve race condition in memory store" (#1271)
Revert "fix(storage): resolve race condition in memory store (#1270)"

This reverts commit 83c4fac217.
2025-09-17 15:10:08 -04:00
TwiN
83c4fac217 fix(storage): resolve race condition in memory store (#1270)
* fix(storage): resolve race condition in memory store

* fix: resolve variable shadowing in CopyEndpointStatus

* fix: update test files to use CopyEndpointStatus function
2025-09-17 08:43:11 -04:00
TwiN
37ba305c34 fix: Don't panic on if there's 0 endpoints and >1 suite + update documentation (#1266) 2025-09-16 16:56:36 -04:00
dependabot[bot]
39ace63224 chore(deps): bump github.com/prometheus-community/pro-bing from 0.6.1 to 0.7.0 (#1075)
chore(deps): bump github.com/prometheus-community/pro-bing

Bumps [github.com/prometheus-community/pro-bing](https://github.com/prometheus-community/pro-bing) from 0.6.1 to 0.7.0.
- [Release notes](https://github.com/prometheus-community/pro-bing/releases)
- [Changelog](https://github.com/prometheus-community/pro-bing/blob/main/.goreleaser.yaml)
- [Commits](https://github.com/prometheus-community/pro-bing/compare/v0.6.1...v0.7.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus-community/pro-bing
  dependency-version: 0.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-16 16:53:33 -04:00
Sebastian
412b6d30a4 docs: information on escaping endpoint url in config (#1242)
* Update README.md with information on escaping endpoint url in configuration

Clarify usage of environment variables in configuration file and provide guidance for escaping special characters.

* Update README.md

Move comment about escaping to the section with env variables.
2025-09-16 16:29:09 -04:00
dependabot[bot]
0f2b486623 chore(deps): bump actions/setup-go from 5 to 6 (#1243)
Bumps [actions/setup-go](https://github.com/actions/setup-go) from 5 to 6.
- [Release notes](https://github.com/actions/setup-go/releases)
- [Commits](https://github.com/actions/setup-go/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/setup-go
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-09-16 16:27:53 -04:00
TwiN
347394b38a docs: Update list of placeholders for SSH 2025-09-15 13:31:11 -04:00
Denis
daf6ff60f8 fix(client): add forward ip support in PTR query-name (#1261)
* feat(client): add forward ip support in PTR query-name

* fix(identation): spaces back to tabs

* Update client/client.go

* Update client/client.go

---------

Co-authored-by: Denis Evers <git@evers.sh>
Co-authored-by: TwiN <twin@linux.com>
2025-09-15 09:52:57 -04:00
TwiN
f4001d0d80 docs: Mention gatus-cli as alternative to push external endpoint result 2025-09-14 20:58:12 -04:00
TwiN
65af0c9377 docs(client): Clarify usage of [DOMAIN_EXPIRATION] placeholder
Updated the explanation for the [DOMAIN_EXPIRATION] placeholder to clarify the usage of RDAP.
2025-09-11 16:36:11 -04:00
ju-ef
af4fbac84d feat(client): Add RDAP support for domain expiration (#1181)
Fixes #1083

Fixes #1254

Co-authored-by: TwiN <twin@linux.com>
2025-09-11 16:32:19 -04:00
TwiN
39bfc51ce4 fix(storage): race issue with memory store (#1256) 2025-09-11 14:13:31 -04:00
eleith
c006b35871 feat(client): starttls support for dns resolver (#1253)
* customize starttls dialup connection if dnsresolver has a value, mirroring http client

* add starttls connection test with a dns resolver

---------

Co-authored-by: eleith <online-github@eleith.com>
2025-09-11 07:48:49 -04:00
TwiN
e3cae4637c fix(storage): Create suite-related tables before endpoint-related tables to avoid reference issues (#1251)
Fixes #1250
2025-09-10 22:08:58 -04:00
TwiN
3d61f5fe60 docs(alerting): Remove untested notice for Line alerts 2025-09-09 14:52:07 -04:00
TwiN
d559990162 fix(alerting): Don't suffix Signal API URL with /v2/send if it already has that suffix
https://github.com/TwiN/gatus/discussions/1223#discussioncomment-1433423
2025-09-08 19:04:55 -04:00
TwiN
f7fe56efa1 fix(ui): Don't iterate over null array
Fixes #1248
2025-09-06 06:34:27 -04:00
TwiN
d668a14703 feat(suite): Implement Suites (#1239)
* feat(suite): Implement Suites

Fixes #1230

* Update docs

* Fix variable alignment

* Prevent always-run endpoint from running if a context placeholder fails to resolve in the URL

* Return errors when a context placeholder path fails to resolve

* Add a couple of unit tests

* Add a couple of unit tests

* fix(ui): Update group count properly

Fixes #1233

* refactor: Pass down entire config instead of several sub-configs

* fix: Change default suite interval and timeout

* fix: Deprecate disable-monitoring-lock in favor of concurrency

* fix: Make sure there are no duplicate keys

* Refactor some code

* Update watchdog/watchdog.go

* Update web/app/src/components/StepDetailsModal.vue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* chore: Remove useless log

* fix: Set default concurrency to 3 instead of 5

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-09-05 15:39:12 -04:00
TwiN
10cabb9dde fix(alerting): Prevent >2nd reminders from being skipped if the first one triggered
See https://github.com/TwiN/gatus/pull/1226#issuecomment-3223818252
2025-08-26 17:02:30 -04:00
dependabot[bot]
3580bbb41b chore(deps): bump github.com/miekg/dns from 1.1.67 to 1.1.68 (#1192)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.67 to 1.1.68.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.67...v1.1.68)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.68
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-08-26 09:31:21 -04:00
dependabot[bot]
3a47d64610 chore(deps): bump codecov/codecov-action from 5.4.3 to 5.5.0 (#1215)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.3 to 5.5.0.
- [Release notes](https://github.com/codecov/codecov-action/releases)
- [Changelog](https://github.com/codecov/codecov-action/blob/main/CHANGELOG.md)
- [Commits](https://github.com/codecov/codecov-action/compare/v5.4.3...v5.5.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-26 09:31:05 -04:00
dependabot[bot]
2fa197d5bf chore(deps): bump github.com/prometheus/client_golang from 1.22.0 to 1.23.0 (#1184)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.22.0 to 1.23.0.
- [Release notes](https://github.com/prometheus/client_golang/releases)
- [Changelog](https://github.com/prometheus/client_golang/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prometheus/client_golang/compare/v1.22.0...v1.23.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-08-26 09:30:47 -04:00
Sean Kelly
d41cfc0d16 fix(alerting): Adjust minimum reminder config parsing (#1226)
* Update minimum repeat interval parsing

* Update minimum repeat interval parsing
2025-08-25 23:31:23 -04:00
TwiN
a49b9145d2 feat(alerting): Add new providers for Datadog, IFTTT, Line, NewRelic, Plivo, RocketChat, SendGrid, Signal, SIGNL4, Splunk, Squadcast, Vonage, Webex and Zapier (#1224)
* feat(alerting): Add new providers for Datadog, IFTTT, Line, NewRelic, Plivo, RocketChat, SendGrid, Signal, SIGNL4, Splunk, Squadcast, Vonage, Webex and Zapier

Relevant: https://github.com/TwiN/gatus/discussions/1223

Fixes #1073
Fixes #1074

* chore: Clean up code

* docs: Fix table formatting

* Update alerting/provider/datadog/datadog.go

* Update alerting/provider/signal/signal.go

* Update alerting/provider/ifttt/ifttt.go

* Update alerting/provider/newrelic/newrelic.go

* Update alerting/provider/squadcast/squadcast.go

* Update alerting/provider/squadcast/squadcast.go
2025-08-25 13:22:17 -04:00
XavierDupuis
6e888430fa docs: Fix typo in Zulip configuration section (#1220)
Fix alert type in Zulip configuration section
2025-08-24 15:59:04 -04:00
TwiN
7dac2cc3f5 fix(remote): Set default page size to 50
Addresses https://github.com/TwiN/gatus/issues/64#issuecomment-3214237871
2025-08-22 18:59:09 -04:00
TwiN
b875ba4dfe docs(ui): Clarify how to sort by group by default 2025-08-21 10:11:06 -04:00
Andrej Vaňo
3e713dfee3 docs(alerting): Fix the homeassistant event structure example (#1213)
docs(Homeassistant): Fix the event structure in the example
2025-08-19 13:03:59 -04:00
TwiN
2f99eccf5f fix(ui): Collapse groups by default (#1212) 2025-08-19 10:15:35 -04:00
TwiN
d37f71eee7 fix(ui): Move announcements above endpoints search bar (#1210) 2025-08-19 07:32:04 -04:00
Salim B
6d579a4b48 docs: improve extra-labels description (#1208)
* docs: improve `extra-labels` description

* Update README.md

* Update README.md

---------

Co-authored-by: TwiN <twin@linux.com>
2025-08-18 14:18:57 -04:00
TwiN
2c42aa8087 fix(ui): Update endpoints in-place instead of reloading every time (#1209)
* fix(ui): Update endpoints in-place instead of reloading every time

Fixes #1207

* Regenerate static assets
2025-08-18 12:36:16 -04:00
TwiN
12825a2b6f ci: Fix typo in if statement 2025-08-18 12:18:24 -04:00
dependabot[bot]
e1edc15337 chore(deps): bump actions/checkout from 4 to 5 (#1197)
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-17 18:33:18 -04:00
Bo-Yi Wu
d17f51a1a1 feat(alerting): Support extra labels in email alerts (#1191)
feat: add support for extra labels in alert email notifications

- Add support for including extra labels in email alert messages if present
- Update tests to cover cases with and without extra labels in alert emails

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-08-17 12:28:04 -04:00
TwiN
a9fb48b26c chore: Regenerate static content 2025-08-16 10:01:13 -04:00
TwiN
131447f702 feat: Implement announcements (#1204)
* feat: Implement announcements

Fixes #1203

* Remove unnecessary code

* Fix new announcement test

* Update web/app/src/views/Home.vue

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Remove useless garbage

* Require announcement timestamp

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-08-16 09:54:50 -04:00
TwiN
609a634df3 ci: Tag area/ui if the PR title has ui in it 2025-08-14 16:48:41 -04:00
TwiN
6c28de6950 fix(ui): Replace filter value "nothing" by "none" (#1202) 2025-08-14 16:44:17 -04:00
TwiN
440b732c71 feat(ui): New status page UI (#1198)
* feat(ui): New status page UI

* docs: Rename labels to extra-labels

* Fix domain expiration test

* feat(ui): Add ui.default-sort-by and ui.default-filter-by

* Change ui.header default value to Gatus

* Re-use EndpointCard in Details.vue as well to avoid duplicate code

* Fix flaky metrics test

* Add subtle green color to "Gatus"

* Remove duplicate title (tooltip is sufficient, no need for title on top of that)

* Fix collapsed group user preferences

* Update status page screenshots
2025-08-14 09:15:34 -04:00
Salim B
8d63462fcd docs: tweak minimum-reminder-interval description (#1194) 2025-08-11 10:43:34 -04:00
TwiN
daf67dc1e6 perf: Cap RANDOM_STRING_N to 8182 (#1193) 2025-08-11 09:36:55 -04:00
TwiN
3ebed01b4c docs: Add section for gatus-sdk (#1189) 2025-08-10 10:18:10 -04:00
TwiN
a2f5516b06 ci(labeler): Support documentation label 2025-08-10 10:18:02 -04:00
dependabot[bot]
a68e7e39bd chore(deps): bump modernc.org/sqlite from 1.38.0 to 1.38.2 (#1188)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.38.0 to 1.38.2.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.38.0...v1.38.2)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.38.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-08-09 21:27:12 -04:00
Sean Kelly
f9d7320a2a docs: Add example for alerts[].minimum-reminder-interval (#1187)
Update README.md for minimum-reminder-interval
2025-08-09 10:41:12 -04:00
Viktor Ziegler
c374649019 feat(alerting): Implement alert reminders (#1138)
* feat(alerting): add reminder-interval feature which allows setting an interval to run alert consecutively

* feat(test): add tests for reminder-interval feature

* feat(docs): modify documentation for reminder-interval feature

* chore: change "due" to "TRIGGERED" for easier log look through

* chore: update "reminder-interval" to "repeat-interval"

* chore: update reminder-interval to repeat-interval

* chore: adapt repeat interval feature after merge

* chore: adapt repeat interval feature after merge

* RepeatInterval => MinimumRepeatInterval

* fix merge issues

(cherry picked from commit 9b2161556bddf01d385f97dafac2515857190ae5)

* rename and move MiniumRepeatInterval

* move MiniumRepeatInterval (again)

---------

Co-authored-by: Bugra Kocabay <kocabay.bugra@gmail.com>
Co-authored-by: Bugra Kocabay <kocabaybugra@gmail.com>
Co-authored-by: Konstantin Nosov <nosovk@gmail.com>
Co-authored-by: Viktor Ziegler <Viktor.Ziegler@ti8m.ch>
Co-authored-by: TwiN <twin@linux.com>
2025-08-07 21:35:44 -04:00
dependabot[bot]
f6e938746f chore(deps): bump github.com/aws/aws-sdk-go from 1.55.7 to 1.55.8 (#1183)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.55.7 to 1.55.8.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Changelog](https://github.com/aws/aws-sdk-go/blob/main/CHANGELOG_PENDING.md)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.55.7...v1.55.8)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  dependency-version: 1.55.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-08-07 21:20:10 -04:00
TwiN
2c6fede468 ci: Bump timeout from 2h to 4h
The speed of the runners is getting worse every day...
2025-08-06 16:21:38 -04:00
Bo-Yi Wu
9205cb2890 fix(metrics): improve Prometheus metrics registration (#1186)
feat: improve Prometheus metrics registration and cleanup

- Add a function to unregister all previously registered Prometheus metrics
- Track metric initialization state to prevent duplicate registration
- Ensure metrics are unregistered before re-initializing them
- Store the current registerer for proper metric cleanup
- Call the new unregister function during application stop

ref: https://github.com/TwiN/gatus/pull/979#issuecomment-3157044249

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2025-08-06 08:04:08 -04:00
Bo-Yi Wu
6a9cbb1728 feat(metrics): add support for custom labels in Prometheus metrics (#979)
* feat: add dynamic labels support for Prometheus metrics

- Add `toBoolPtr` function to convert a bool to a bool pointer
- Add `contains` function to check if a key exists in a slice
- Add `GetMetricLabels` method to `Config` struct to return unique metric labels from enabled endpoints
- Change file permission notation from `0644` to `0o644` in `config_test.go`
- Add `Labels` field to `Endpoint` struct for key-value pairs
- Initialize Prometheus metrics with dynamic labels from configuration
- Modify `PublishMetricsForEndpoint` to include dynamic labels
- Add test for `GetMetricLabels` method in `config_test.go`
- Update `watchdog` to pass labels to monitoring and execution functions

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* refactor: refactor pointer conversion utility and update related tests

- Rename `toBoolPtr` function to a generic `toPtr` function
- Update tests to use the new `toPtr` function instead of `toBoolPtr`

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* refactor: refactor utility functions and improve test coverage

- Move `toPtr` and `contains` utility functions to a new file `util.go`

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* missing labels parameter

* refactor: reorder parameters in metrics-related functions and tests

- Reorder parameters in `PublishMetricsForEndpoint` function
- Update test cases to match the new parameter order in `PublishMetricsForEndpoint`
- Reorder parameters in `monitor` function
- Adjust `monitor` function calls to match the new parameter order
- Reorder parameters in `execute` function call to `PublishMetricsForEndpoint`

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* Update main.go

* Update config/config.go

* docs: improve documentation formatting, examples, and readability

- Add multiple blank lines for spacing in the README file
- Fix formatting issues in markdown tables
- Correct deprecated formatting for Teams alerts
- Replace single quotes with double quotes in JSON examples
- Add new sections and examples for various configurations and endpoints
- Improve readability and consistency in the documentation
- Update links and references to examples and configurations

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* docs: enhance custom labels support in Prometheus metrics

- Add a section for custom labels in the README
- Include an example configuration for custom labels in Prometheus metrics initialization

Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>

* refactor: rename and refactor metric labels to use ExtraLabels

- Rename the endpoint metric labels field from Labels to ExtraLabels and update its YAML tag accordingly
- Update code and tests to use ExtraLabels instead of Labels for metrics
- Replace GetMetricLabels with GetUniqueExtraMetricLabels and adjust usages throughout the codebase
- Ensure all metric publishing and monitoring functions accept and use the new extraLabels naming and semantics
- Update tests to verify correct extraction and handling of ExtraLabels for enabled endpoints

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* refactor: refactor parameter order for monitor and execute for consistency

- Change the order of parameters for monitor and execute functions to group extraLabels consistently as the last argument before context.
- Update all relevant function calls and signatures to reflect the new parameter order.
- Replace usage of labels with extraLabels for clarity and consistency.

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* test: improve initialization and labeling of Prometheus metrics

- Add a test to verify that Prometheus metrics initialize correctly with extra labels.
- Ensure metrics variables are properly initialized and not nil.
- Check that WithLabelValues accepts both default and extra labels without causing a panic.

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* test: improve Prometheus metrics testing for extra label handling

- Remove a redundant test for WithLabelValues label length.
- Add a new test to verify that extraLabels are correctly included in exported Prometheus metrics.

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* refactor: refactor metrics to support custom Prometheus registries

- Refactor metrics initialization to accept a custom Prometheus registry, defaulting to the global registry when nil
- Replace promauto with direct metric construction and explicit registration
- Update tests to use dedicated, isolated registries instead of the default global registry

Signed-off-by: appleboy <appleboy.tw@gmail.com>

* Revert README.md to a previous version

* docs: document support for custom metric labels in endpoints

- Add documentation section explaining support for custom labels on metrics
- Provide YAML configuration example illustrating the new labels field for endpoints
- Update table of contents to include the custom labels section

Signed-off-by: appleboy <appleboy.tw@gmail.com>

---------

Signed-off-by: appleboy <appleboy.tw@gmail.com>
Signed-off-by: Bo-Yi Wu <appleboy.tw@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2025-08-05 12:26:50 -04:00
TwiN
4667fdbc15 chore: Use the right logging library 2025-08-02 17:31:53 -04:00
192 changed files with 22002 additions and 3175 deletions

BIN
.github/assets/dashboard-conditions.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

BIN
.github/assets/dashboard-dark.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

BIN
.github/assets/endpoint-groups.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

BIN
.github/assets/example.jpg vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

5
.github/codecov.yml vendored
View File

@@ -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:

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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 }}

1089
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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) {

View File

@@ -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"
)

View File

@@ -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"`
}

View 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
}

View 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")
}
}

View File

@@ -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

View File

@@ -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{

View 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
}

View 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")
}
}

View File

@@ -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":""}`,
},
}

View 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
}

View 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)
}

View File

@@ -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))]
}

View 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
}

View 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")
}
}

View 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
}

View 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)
}
}

View File

@@ -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)
)

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View 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
}

View 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")
}
}

View 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
}

View 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)
}
}

View 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
}

View 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")
}
}

View 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
}

View 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)
}
}

View File

@@ -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",

View File

@@ -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{

View 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
}

View 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")
}
}

View 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
}

View 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")
}
}

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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

View File

@@ -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())
}

View File

@@ -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) {

View 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
}

View 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)
}
})
}
}

View 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
}

View 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")
}
}

View 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
}

View 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")
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {

View File

@@ -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)
}

View File

@@ -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))
}
}

View File

@@ -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{}

View File

@@ -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("")

View File

@@ -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 {

View File

@@ -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
View 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
View 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
}())))
}

View File

@@ -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) {

View File

@@ -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
}

View File

@@ -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{

View File

@@ -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
}

View File

@@ -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

View 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
}

View File

@@ -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)
}
}

View File

@@ -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)
}
})
}
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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")
}
})
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -1,11 +0,0 @@
package endpoint
import (
"testing"
)
func BenchmarkConvertGroupAndEndpointNameToKey(b *testing.B) {
for n := 0; n < b.N; n++ {
ConvertGroupAndEndpointNameToKey("group", "name")
}
}

View 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
}
}

View 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)
}
})
}
}

View File

@@ -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+"")
}

View File

@@ -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
View 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
}
}

View 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")
}
}
})
}
}

View File

@@ -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
}
}

View File

@@ -0,0 +1,11 @@
package key
import (
"testing"
)
func BenchmarkConvertGroupAndNameToKey(b *testing.B) {
for n := 0; n < b.N; n++ {
ConvertGroupAndNameToKey("group", "name")
}
}

View File

@@ -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)
}

Some files were not shown because too many files have changed in this diff Show More