Compare commits

...

63 Commits

Author SHA1 Message Date
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
dependabot[bot]
501b71cab5 chore(deps): bump github.com/miekg/dns from 1.1.66 to 1.1.67 (#1166)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.66 to 1.1.67.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.66...v1.1.67)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.67
  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-02 14:54:41 -04:00
dependabot[bot]
196be2b89c chore(deps): bump github.com/valyala/fasthttp from 1.62.0 to 1.64.0 (#1162)
---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.64.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-02 14:07:46 -04:00
Olexandr Dovgun
d27c63ded7 feat(websocket): add support for custom headers in WS check (#1180)
feat(websocket): add support for custom headers in QueryWebSocket function
2025-08-02 14:06:46 -04:00
Sevan
8c5ad54e71 chore(deps): Bump TwiN/whois dependency to v1.1.11 (#1175) 2025-07-31 09:05:20 -04:00
TwiN
6f9a2c7c32 feat(external-endpoint): Implement heartbeat (#1173)
Fixes #741
2025-07-30 12:18:10 -04:00
Yaroslav
aa08321239 fix(websocket): use tls config (#1165)
Co-authored-by: TwiN <twin@linux.com>
2025-07-29 14:49:36 -04:00
dependabot[bot]
ad5197f037 chore(deps): bump google.golang.org/api from 0.236.0 to 0.242.0 (#1164)
---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.242.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-07-20 23:29:29 -04:00
jasonshugart
bdaffbca77 feat: Add body to TCP, UDP, and TLS endpoints and templating (#1134)
* feat(endpoints): Add body to TCP, UDP, and TLS endpoints and templating

* Changed the template to be more consistent with the
rest of the application and added additional substritutions.

* Changed getModifiedBody to getParsedBody and fixed connected response

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Apply suggestion from @TwiN

* Update client/client.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-07-19 21:24:03 -04:00
Jon Fuller
f4a667549e docs: Update example Grafana dashboard (#1163)
* Update gatus.json

* Update gatus.json
2025-07-16 18:54:56 -04:00
Dmitry
00419a4b4a feat(alerting): Support sending messages to Telegram topics in group (#928)
* feat(alerting): Added the ability to send messages to Telegram topics in groups.

* feat(alerting): Added the ability to send messages to Telegram topics in groups.

fix tests

* feat(alerting): Added the ability to send messages to Telegram topics in groups.

Rename TopicId to TopicID

* feat(alerting): Added the ability to send messages to Telegram topics in groups.

Fixed description for alerting.telegram.topic-id in README.md

---------

Co-authored-by: TwiN <twin@linux.com>
2025-07-11 13:55:42 -04:00
181 changed files with 21443 additions and 3519 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
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
- 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 }}

1022
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"
@@ -35,6 +36,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

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

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

@@ -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")
@@ -23,16 +23,17 @@ var (
)
type Config struct {
Token string `yaml:"token"`
ID string `yaml:"id"`
ApiUrl string `yaml:"api-url"`
Token string `yaml:"token"`
ID string `yaml:"id"`
TopicID string `yaml:"topic-id,omitempty"`
ApiUrl string `yaml:"api-url"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.ApiUrl) == 0 {
cfg.ApiUrl = defaultApiUrl
cfg.ApiUrl = ApiURL
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
@@ -53,6 +54,9 @@ func (cfg *Config) Merge(override *Config) {
if len(override.ID) > 0 {
cfg.ID = override.ID
}
if len(override.TopicID) > 0 {
cfg.TopicID = override.TopicID
}
if len(override.ApiUrl) > 0 {
cfg.ApiUrl = override.ApiUrl
}
@@ -117,6 +121,7 @@ type Body struct {
ChatID string `json:"chat_id"`
Text string `json:"text"`
ParseMode string `json:"parse_mode"`
TopicID string `json:"message_thread_id,omitempty"`
}
// buildRequestBody builds the request body for the provider
@@ -150,6 +155,7 @@ func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoi
ChatID: cfg.ID,
Text: text,
ParseMode: "MARKDOWN",
TopicID: cfg.TopicID,
})
return bodyAsJSON
}

View File

@@ -154,6 +154,13 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Resolved: true,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been resolved:\\n—\\n _healthcheck passing successfully 5 time(s) in a row_\\n— \\n*Description* \\n_description-2_ \\n\",\"parse_mode\":\"MARKDOWN\"}",
},
{
Name: "send to topic",
Provider: AlertProvider{DefaultConfig: Config{ID: "123", TopicID: "7"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"chat_id\":\"123\",\"text\":\"⛑ *Gatus* \\nAn alert for *endpoint-name* has been triggered:\\n—\\n _healthcheck failed 3 time(s) in a row_\\n— \\n*Description* \\n_description-1_ \\n\\n*Condition results*\\n❌ - `[CONNECTED] == true`\\n❌ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\",\"message_thread_id\":\"7\"}",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {

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

@@ -75,7 +75,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 +87,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 +128,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

@@ -7,6 +7,7 @@ import (
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/metrics"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog"
@@ -15,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"]
@@ -58,7 +60,7 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
result.Errors = append(result.Errors, c.Query("error"))
}
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())
}
@@ -72,6 +74,9 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
}
if cfg.Metrics {
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,9 +1,11 @@
package client
import (
"context"
"crypto/tls"
"crypto/x509"
"encoding/json"
"encoding/hex"
"errors"
"fmt"
"io"
@@ -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)
}
@@ -76,24 +86,37 @@ func GetDomainExpiration(hostname string) (domainExpiration time.Duration, err e
return domainExpiration, nil
}
// CanCreateTCPConnection checks whether a connection can be established with a TCP endpoint
func CanCreateTCPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("tcp", address, config.Timeout)
if err != nil {
return false
}
_ = conn.Close()
return true
// parseLocalAddressPlaceholder returns a string with the local address replaced
func parseLocalAddressPlaceholder(item string, localAddr net.Addr) string {
item = strings.ReplaceAll(item, "[LOCAL_ADDRESS]", localAddr.String())
return item
}
// CanCreateUDPConnection checks whether a connection can be established with a UDP endpoint
func CanCreateUDPConnection(address string, config *Config) bool {
conn, err := net.DialTimeout("udp", address, config.Timeout)
// CanCreateNetworkConnection checks whether a connection can be established with a TCP or UDP endpoint
func CanCreateNetworkConnection(netType string, address string, body string, config *Config) (bool, []byte) {
const (
MaximumMessageSize = 1024 // in bytes
)
connection, err := net.DialTimeout(netType, address, config.Timeout)
if err != nil {
return false
return false, nil
}
_ = conn.Close()
return true
defer connection.Close()
if body != "" {
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
connection.SetDeadline(time.Now().Add(config.Timeout))
_, err = connection.Write([]byte(body))
if err != nil {
return false, nil
}
buf := make([]byte, MaximumMessageSize)
n, err := connection.Read(buf)
if err != nil {
return false, nil
}
return true, buf[:n]
}
return true, nil
}
// CanCreateSCTPConnection checks whether a connection can be established with a SCTP endpoint
@@ -128,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
@@ -152,7 +204,10 @@ func CanPerformStartTLS(address string, config *Config) (connected bool, certifi
}
// CanPerformTLS checks whether a connection can be established to an address using the TLS protocol
func CanPerformTLS(address string, config *Config) (connected bool, certificate *x509.Certificate, err error) {
func CanPerformTLS(address string, body string, config *Config) (connected bool, response []byte, certificate *x509.Certificate, err error) {
const (
MaximumMessageSize = 1024 // in bytes
)
connection, err := tls.DialWithDialer(&net.Dialer{Timeout: config.Timeout}, "tcp", address, &tls.Config{
InsecureSkipVerify: config.Insecure,
})
@@ -166,9 +221,27 @@ func CanPerformTLS(address string, config *Config) (connected bool, certificate
// Reference: https://pkg.go.dev/crypto/tls#PeerCertificates
if len(verifiedChains) == 0 || len(verifiedChains[0]) == 0 {
peerCertificates := connection.ConnectionState().PeerCertificates
return true, peerCertificates[0], nil
certificate = peerCertificates[0]
} else {
certificate = verifiedChains[0][0]
}
return true, verifiedChains[0][0], nil
connected = true
if body != "" {
body = parseLocalAddressPlaceholder(body, connection.LocalAddr())
connection.SetDeadline(time.Now().Add(config.Timeout))
_, err = connection.Write([]byte(body))
if err != nil {
return
}
buf := make([]byte, MaximumMessageSize)
var n int
n, err = connection.Read(buf)
if err != nil {
return
}
response = buf[:n]
}
return
}
// CanCreateSSHConnection checks whether a connection can be established and a command can be executed to an address
@@ -234,6 +307,7 @@ func ExecuteSSHCommand(sshClient *ssh.Client, body string, config *Config) (bool
}
defer sshClient.Close()
var b Body
body = parseLocalAddressPlaceholder(body, sshClient.Conn.LocalAddr())
if err := json.Unmarshal([]byte(body), &b); err != nil {
return false, 0, err
}
@@ -286,7 +360,7 @@ func Ping(address string, config *Config) (bool, time.Duration) {
}
// QueryWebSocket opens a websocket connection, write `body` and return a message from the server
func QueryWebSocket(address, body string, config *Config) (bool, []byte, error) {
func QueryWebSocket(address, body string, headers map[string]string, config *Config) (bool, []byte, error) {
const (
Origin = "http://localhost/"
MaximumMessageSize = 1024 // in bytes
@@ -295,8 +369,22 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
if err != nil {
return false, nil, fmt.Errorf("error configuring websocket connection: %w", err)
}
if headers != nil {
if wsConfig.Header == nil {
wsConfig.Header = make(http.Header)
}
for name, value := range headers {
wsConfig.Header.Set(name, value)
}
}
if config != nil {
wsConfig.Dialer = &net.Dialer{Timeout: config.Timeout}
wsConfig.TlsConfig = &tls.Config{
InsecureSkipVerify: config.Insecure,
}
if config.HasTLSConfig() && config.TLS.isValid() == nil {
wsConfig.TlsConfig = configureTLS(wsConfig.TlsConfig, *config.TLS)
}
}
// Dial URL
ws, err := websocket.DialConfig(wsConfig)
@@ -304,6 +392,7 @@ func QueryWebSocket(address, body string, config *Config) (bool, []byte, error)
return false, nil, fmt.Errorf("error dialing websocket: %w", err)
}
defer ws.Close()
body = parseLocalAddressPlaceholder(body, ws.LocalAddr())
// Write message
if _, err := ws.Write([]byte(body)); err != nil {
return false, nil, fmt.Errorf("error writing websocket body: %w", err)
@@ -322,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)
@@ -373,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
@@ -223,7 +247,7 @@ func TestCanPerformTLS(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
connected, _, err := CanPerformTLS(tt.args.address, &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
connected, _, _, err := CanPerformTLS(tt.args.address, "", &Config{Insecure: tt.args.insecure, Timeout: 5 * time.Second})
if (err != nil) != tt.wantErr {
t.Errorf("CanPerformTLS() err=%v, wantErr=%v", err, tt.wantErr)
return
@@ -235,11 +259,13 @@ func TestCanPerformTLS(t *testing.T) {
}
}
func TestCanCreateTCPConnection(t *testing.T) {
if CanCreateTCPConnection("127.0.0.1", &Config{Timeout: 5 * time.Second}) {
func TestCanCreateConnection(t *testing.T) {
connected, _ := CanCreateNetworkConnection("tcp", "127.0.0.1", "", &Config{Timeout: 5 * time.Second})
if connected {
t.Error("should've failed, because there's no port in the address")
}
if !CanCreateTCPConnection("1.1.1.1:53", &Config{Timeout: 5 * time.Second}) {
connected, _ = CanCreateNetworkConnection("tcp", "1.1.1.1:53", "", &Config{Timeout: 5 * time.Second})
if !connected {
t.Error("should've succeeded, because that IP should always™ be up")
}
}
@@ -303,11 +329,11 @@ func TestHttpClientProvidesOAuth2BearerToken(t *testing.T) {
}
func TestQueryWebSocket(t *testing.T) {
_, _, err := QueryWebSocket("", "body", &Config{Timeout: 2 * time.Second})
_, _, err := QueryWebSocket("", "body", nil, &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the address being invalid")
}
_, _, err = QueryWebSocket("ws://example.org", "body", &Config{Timeout: 2 * time.Second})
_, _, err = QueryWebSocket("ws://example.org", "body", nil, &Config{Timeout: 2 * time.Second})
if err == nil {
t.Error("expected an error due to the target not being websocket-friendly")
}
@@ -425,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

@@ -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,18 @@ 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/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/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 +37,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 +72,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 +92,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 +114,35 @@ type Config struct {
// Connectivity is the configuration for connectivity
Connectivity *connectivity.Config `yaml:"connectivity,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 +286,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 {
@@ -280,6 +320,16 @@ func parseAndValidateConfigBytes(yamlBytes []byte) (config *Config, err error) {
if err := validateConnectivityConfig(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
}
@@ -293,6 +343,17 @@ func validateConnectivityConfig(config *Config) error {
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 {
@@ -365,7 +426,7 @@ func validateEndpointsConfig(config *Config) error {
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 {
@@ -379,6 +440,78 @@ func validateEndpointsConfig(config *Config) error {
return nil
}
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() {
@@ -404,6 +537,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
alertTypes := []alert.Type{
alert.TypeAWSSES,
alert.TypeCustom,
alert.TypeDatadog,
alert.TypeDiscord,
alert.TypeEmail,
alert.TypeGitHub,
@@ -412,21 +546,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
@@ -444,7 +591,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
// 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())
}
}
}
@@ -458,7 +605,7 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
// 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())
}
}
}
@@ -477,3 +624,17 @@ func validateAlertingConfig(alertingConfig *alerting.Config, endpoints []*endpoi
}
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,19 +21,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/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"
@@ -118,13 +135,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 +152,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 +254,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 +299,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 +315,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 +332,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 +730,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")
}
}
@@ -1876,8 +1893,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 +1902,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 +1910,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 +1947,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 +1955,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 +1994,493 @@ 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)
}
})
}
}

View File

@@ -42,7 +42,8 @@ type Checker struct {
}
func (c *Checker) Check() bool {
return client.CanCreateTCPConnection(c.Target, &client.Config{Timeout: 5 * time.Second})
connected, _ := client.CanCreateNetworkConnection("tcp", c.Target, "", &client.Config{Timeout: 5 * time.Second})
return connected
}
func (c *Checker) IsConnected() bool {

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

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

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

View File

@@ -7,9 +7,12 @@ import (
"errors"
"fmt"
"io"
"math/rand"
"net"
"net/http"
"net/url"
"regexp"
"strconv"
"strings"
"time"
@@ -18,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"
)
@@ -96,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"`
@@ -125,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
@@ -229,7 +252,7 @@ func (e *Endpoint) ValidateAndSetDefaults() error {
}
}
// Make sure that the request can be created
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.Body)))
_, err := http.NewRequest(e.Method, e.URL, bytes.NewBuffer([]byte(e.getParsedBody())))
if err != nil {
return err
}
@@ -246,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
@@ -260,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 {
@@ -278,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())
@@ -290,42 +323,114 @@ 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)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", e.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", e.URL)
randRegex, err := regexp.Compile(`\[RANDOM_STRING_\d+\]`)
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 {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
}
return string(b)
})
}
return body
}
func (e *Endpoint) getIP(result *Result) {
if ips, err := net.LookupIP(result.Hostname); err != nil {
result.AddError(err.Error())
@@ -356,7 +461,7 @@ func (e *Endpoint) call(result *Result) {
if endpointType == TypeSTARTTLS {
result.Connected, certificate, err = client.CanPerformStartTLS(strings.TrimPrefix(e.URL, "starttls://"), e.ClientConfig)
} else {
result.Connected, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.ClientConfig)
result.Connected, result.Body, certificate, err = client.CanPerformTLS(strings.TrimPrefix(e.URL, "tls://"), e.getParsedBody(), e.ClientConfig)
}
if err != nil {
result.AddError(err.Error())
@@ -365,10 +470,10 @@ func (e *Endpoint) call(result *Result) {
result.Duration = time.Since(startTime)
result.CertificateExpiration = time.Until(certificate.NotAfter)
} else if endpointType == TypeTCP {
result.Connected = client.CanCreateTCPConnection(strings.TrimPrefix(e.URL, "tcp://"), e.ClientConfig)
result.Connected, result.Body = client.CanCreateNetworkConnection("tcp", strings.TrimPrefix(e.URL, "tcp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeUDP {
result.Connected = client.CanCreateUDPConnection(strings.TrimPrefix(e.URL, "udp://"), e.ClientConfig)
result.Connected, result.Body = client.CanCreateNetworkConnection("udp", strings.TrimPrefix(e.URL, "udp://"), e.getParsedBody(), e.ClientConfig)
result.Duration = time.Since(startTime)
} else if endpointType == TypeSCTP {
result.Connected = client.CanCreateSCTPConnection(strings.TrimPrefix(e.URL, "sctp://"), e.ClientConfig)
@@ -376,7 +481,16 @@ func (e *Endpoint) call(result *Result) {
} else if endpointType == TypeICMP {
result.Connected, result.Duration = client.Ping(strings.TrimPrefix(e.URL, "icmp://"), e.ClientConfig)
} else if endpointType == TypeWS {
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.Body, e.ClientConfig)
wsHeaders := map[string]string{}
if e.Headers != nil {
for k, v := range e.Headers {
wsHeaders[k] = v
}
}
if _, exists := wsHeaders["User-Agent"]; !exists {
wsHeaders["User-Agent"] = GatusUserAgent
}
result.Connected, result.Body, err = client.QueryWebSocket(e.URL, e.getParsedBody(), wsHeaders, e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
@@ -385,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
@@ -401,7 +514,7 @@ func (e *Endpoint) call(result *Result) {
result.AddError(err.Error())
return
}
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.Body, e.ClientConfig)
result.Success, result.HTTPStatus, err = client.ExecuteSSHCommand(cli, e.getParsedBody(), e.ClientConfig)
if err != nil {
result.AddError(err.Error())
return
@@ -435,12 +548,12 @@ func (e *Endpoint) buildHTTPRequest() *http.Request {
var bodyBuffer *bytes.Buffer
if e.GraphQL {
graphQlBody := map[string]string{
"query": e.Body,
"query": e.getParsedBody(),
}
body, _ := json.Marshal(graphQlBody)
bodyBuffer = bytes.NewBuffer(body)
} else {
bodyBuffer = bytes.NewBuffer([]byte(e.Body))
bodyBuffer = bytes.NewBuffer([]byte(e.getParsedBody()))
}
request, _ := http.NewRequest(e.Method, e.URL, bodyBuffer)
for k, v := range e.Headers {

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"
)
@@ -932,3 +933,352 @@ 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"},
},
}
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

@@ -2,13 +2,20 @@ package endpoint
import (
"errors"
"time"
"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"
)
var (
// ErrExternalEndpointWithNoToken is the error with which Gatus will panic if an external endpoint is configured without a token.
ErrExternalEndpointWithNoToken = errors.New("you must specify a token for each external endpoint")
// ErrExternalEndpointHeartbeatIntervalTooLow is the error with which Gatus will panic if an external endpoint's heartbeat interval is less than 10 seconds.
ErrExternalEndpointHeartbeatIntervalTooLow = errors.New("heartbeat interval must be at least 10 seconds")
)
// ExternalEndpoint is an endpoint whose result is pushed from outside Gatus, which means that
@@ -30,6 +37,12 @@ type ExternalEndpoint struct {
// Alerts is the alerting configuration for the endpoint in case of failure
Alerts []*alert.Alert `yaml:"alerts,omitempty"`
// MaintenanceWindow is the configuration for per-endpoint maintenance windows
MaintenanceWindows []*maintenance.Config `yaml:"maintenance-windows,omitempty"`
// Heartbeat is the configuration that checks if the external endpoint has received new results when it should have.
Heartbeat heartbeat.Config `yaml:"heartbeat,omitempty"`
// NumberOfFailuresInARow is the number of unsuccessful evaluations in a row
NumberOfFailuresInARow int `yaml:"-"`
@@ -45,6 +58,10 @@ func (externalEndpoint *ExternalEndpoint) ValidateAndSetDefaults() error {
if len(externalEndpoint.Token) == 0 {
return ErrExternalEndpointWithNoToken
}
if externalEndpoint.Heartbeat.Interval != 0 && externalEndpoint.Heartbeat.Interval < 10*time.Second {
// If the heartbeat interval is set (non-0), it must be at least 10 seconds.
return ErrExternalEndpointHeartbeatIntervalTooLow
}
return nil
}
@@ -66,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

@@ -0,0 +1,11 @@
package heartbeat
import "time"
// Config used to check if the external endpoint has received new results when it should have.
// This configuration is used to trigger alerts when an external endpoint has no new results for a defined period of time
type Config struct {
// Interval is the time interval at which Gatus verifies whether the external endpoint has received new results
// If no new result is received within the interval, the endpoint is marked as failed and alerts are triggered
Interval time.Duration `yaml:"interval"`
}

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.

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

55
config/suite/result.go Normal file
View File

@@ -0,0 +1,55 @@
package suite
import (
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
)
// Result represents the result of a suite execution
type Result struct {
// Name of the suite
Name string `json:"name,omitempty"`
// Group of the suite
Group string `json:"group,omitempty"`
// Success indicates whether all required endpoints succeeded
Success bool `json:"success"`
// Timestamp is when the suite execution started
Timestamp time.Time `json:"timestamp"`
// Duration is how long the entire suite execution took
Duration time.Duration `json:"duration"`
// EndpointResults contains the results of each endpoint execution
EndpointResults []*endpoint.Result `json:"endpointResults"`
// Context is the final state of the context after all endpoints executed
Context map[string]interface{} `json:"-"`
// Errors contains any suite-level errors
Errors []string `json:"errors,omitempty"`
}
// AddError adds an error to the suite result
func (r *Result) AddError(err string) {
r.Errors = append(r.Errors, err)
}
// CalculateSuccess determines if the suite execution was successful
func (r *Result) CalculateSuccess() {
r.Success = true
// Check if any endpoints failed (all endpoints are required)
for _, epResult := range r.EndpointResults {
if !epResult.Success {
r.Success = false
break
}
}
// Also check for suite-level errors
if len(r.Errors) > 0 {
r.Success = false
}
}

214
config/suite/suite.go Normal file
View File

@@ -0,0 +1,214 @@
package suite
import (
"errors"
"fmt"
"strconv"
"time"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/config/gontext"
"github.com/TwiN/gatus/v5/config/key"
)
var (
// ErrSuiteWithNoName is the error returned when a suite has no name
ErrSuiteWithNoName = errors.New("suite must have a name")
// ErrSuiteWithNoEndpoints is the error returned when a suite has no endpoints
ErrSuiteWithNoEndpoints = errors.New("suite must have at least one endpoint")
// ErrSuiteWithDuplicateEndpointNames is the error returned when a suite has duplicate endpoint names
ErrSuiteWithDuplicateEndpointNames = errors.New("suite cannot have duplicate endpoint names")
// ErrSuiteWithInvalidTimeout is the error returned when a suite has an invalid timeout
ErrSuiteWithInvalidTimeout = errors.New("suite timeout must be positive")
// DefaultInterval is the default interval for suite execution
DefaultInterval = 10 * time.Minute
// DefaultTimeout is the default timeout for suite execution
DefaultTimeout = 5 * time.Minute
)
// Suite is a collection of endpoints that are executed sequentially with shared context
type Suite struct {
// Name of the suite. Must be unique.
Name string `yaml:"name"`
// Group the suite belongs to. Used for grouping multiple suites together.
Group string `yaml:"group,omitempty"`
// Enabled defines whether the suite is enabled
Enabled *bool `yaml:"enabled,omitempty"`
// Interval is the duration to wait between suite executions
Interval time.Duration `yaml:"interval,omitempty"`
// Timeout is the maximum duration for the entire suite execution
Timeout time.Duration `yaml:"timeout,omitempty"`
// InitialContext holds initial values that can be referenced by endpoints
InitialContext map[string]interface{} `yaml:"context,omitempty"`
// Endpoints in the suite (executed sequentially)
Endpoints []*endpoint.Endpoint `yaml:"endpoints"`
}
// IsEnabled returns whether the suite is enabled
func (s *Suite) IsEnabled() bool {
if s.Enabled == nil {
return true
}
return *s.Enabled
}
// Key returns a unique key for the suite
func (s *Suite) Key() string {
return key.ConvertGroupAndNameToKey(s.Group, s.Name)
}
// ValidateAndSetDefaults validates the suite configuration and sets default values
func (s *Suite) ValidateAndSetDefaults() error {
// Validate name
if len(s.Name) == 0 {
return ErrSuiteWithNoName
}
// Validate endpoints
if len(s.Endpoints) == 0 {
return ErrSuiteWithNoEndpoints
}
// Check for duplicate endpoint names
endpointNames := make(map[string]bool)
for _, ep := range s.Endpoints {
if endpointNames[ep.Name] {
return fmt.Errorf("%w: duplicate endpoint name '%s'", ErrSuiteWithDuplicateEndpointNames, ep.Name)
}
endpointNames[ep.Name] = true
// Suite endpoints inherit the group from the suite
ep.Group = s.Group
// Validate each endpoint
if err := ep.ValidateAndSetDefaults(); err != nil {
return fmt.Errorf("invalid endpoint '%s': %w", ep.Name, err)
}
}
// Set default interval
if s.Interval == 0 {
s.Interval = DefaultInterval
}
// Set default timeout
if s.Timeout == 0 {
s.Timeout = DefaultTimeout
}
// Validate timeout
if s.Timeout < 0 {
return ErrSuiteWithInvalidTimeout
}
// Initialize context if nil
if s.InitialContext == nil {
s.InitialContext = make(map[string]interface{})
}
return nil
}
// Execute executes all endpoints in the suite sequentially with context sharing
func (s *Suite) Execute() *Result {
start := time.Now()
// Initialize context from suite configuration
ctx := gontext.New(s.InitialContext)
// Create suite result
result := &Result{
Name: s.Name,
Group: s.Group,
Success: true,
Timestamp: start,
EndpointResults: make([]*endpoint.Result, 0, len(s.Endpoints)),
}
// Set up timeout for the entire suite execution
timeoutChan := time.After(s.Timeout)
// Execute each endpoint sequentially
suiteHasFailed := false
for _, ep := range s.Endpoints {
// Skip non-always-run endpoints if suite has already failed
if suiteHasFailed && !ep.AlwaysRun {
continue
}
// Check timeout
select {
case <-timeoutChan:
result.AddError(fmt.Sprintf("suite execution timed out after %v", s.Timeout))
result.Success = false
break
default:
}
// Execute endpoint with context
epStartTime := time.Now()
epResult := ep.EvaluateHealthWithContext(ctx)
epDuration := time.Since(epStartTime)
// Set endpoint name, timestamp, and duration on the result
epResult.Name = ep.Name
epResult.Timestamp = epStartTime
epResult.Duration = epDuration
// Store values from the endpoint result if configured (always store, even on failure)
if ep.Store != nil {
_, err := StoreResultValues(ctx, ep.Store, epResult)
if err != nil {
epResult.AddError(fmt.Sprintf("failed to store values: %v", err))
}
}
result.EndpointResults = append(result.EndpointResults, epResult)
// Mark suite as failed on any endpoint failure
if !epResult.Success {
result.Success = false
suiteHasFailed = true
}
}
result.Context = ctx.GetAll()
result.Duration = time.Since(start)
result.CalculateSuccess()
return result
}
// StoreResultValues extracts values from an endpoint result and stores them in the gontext
func StoreResultValues(ctx *gontext.Gontext, mappings map[string]string, result *endpoint.Result) (map[string]interface{}, error) {
if mappings == nil || len(mappings) == 0 {
return nil, nil
}
storedValues := make(map[string]interface{})
for contextKey, placeholder := range mappings {
value, err := extractValueForStorage(placeholder, result)
if err != nil {
// Continue storing other values even if one fails
storedValues[contextKey] = fmt.Sprintf("ERROR: %v", err)
continue
}
if err := ctx.Set(contextKey, value); err != nil {
return storedValues, fmt.Errorf("failed to store %s: %w", contextKey, err)
}
storedValues[contextKey] = value
}
return storedValues, nil
}
// extractValueForStorage extracts a value from an endpoint result for storage in context
func extractValueForStorage(placeholder string, result *endpoint.Result) (interface{}, error) {
// Use the unified ResolvePlaceholder function (no context needed for extraction)
resolved, err := endpoint.ResolvePlaceholder(placeholder, result, nil)
if err != nil {
return nil, err
}
// Try to parse as number or boolean to store as proper types
// Try int first for whole numbers
if num, err := strconv.ParseInt(resolved, 10, 64); err == nil {
return num, nil
}
// Then try float for decimals
if num, err := strconv.ParseFloat(resolved, 64); err == nil {
return num, nil
}
// Then try boolean
if boolVal, err := strconv.ParseBool(resolved); err == nil {
return boolVal, nil
}
return resolved, nil
}

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