Compare commits

...
191 Commits
Author SHA1 Message Date
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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 WuandGitHub 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
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
TwiNandGitHub 6c28de6950 fix(ui): Replace filter value "nothing" by "none" (#1202) 2025-08-14 16:44:17 -04:00
TwiNandGitHub 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 BandGitHub 8d63462fcd docs: tweak minimum-reminder-interval description (#1194) 2025-08-11 10:43:34 -04:00
TwiNandGitHub daf67dc1e6 perf: Cap RANDOM_STRING_N to 8182 (#1193) 2025-08-11 09:36:55 -04:00
TwiNandGitHub 3ebed01b4c docs: Add section for gatus-sdk (#1189) 2025-08-10 10:18:10 -04:00
TwiNandGitHub a2f5516b06 ci(labeler): Support documentation label 2025-08-10 10:18:02 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
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 KellyandGitHub 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
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
TwiNandGitHub 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 WuandGitHub 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
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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 DovgunandGitHub 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
SevanandGitHub 8c5ad54e71 chore(deps): Bump TwiN/whois dependency to v1.1.11 (#1175) 2025-07-31 09:05:20 -04:00
TwiNandGitHub 6f9a2c7c32 feat(external-endpoint): Implement heartbeat (#1173)
Fixes #741
2025-07-30 12:18:10 -04:00
aa08321239 fix(websocket): use tls config (#1165)
Co-authored-by: TwiN <twin@linux.com>
2025-07-29 14:49:36 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
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
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 FullerandGitHub f4a667549e docs: Update example Grafana dashboard (#1163)
* Update gatus.json

* Update gatus.json
2025-07-16 18:54:56 -04:00
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
TwiNandGitHub 7c27fcb895 fix: Convert key to lowercase when looking up endpoint by key (#1150) 2025-07-08 12:21:56 -04:00
TwiNandGitHub 3db5894e90 fix: Limit the pageSize to maximum-number-of-results on first page (#1149) 2025-07-08 12:08:27 -04:00
9b1d15c9e0 feat(api): Add optional duration to external endpoint results (#1092)
* feat(api): Add optional duration to external endpoint results

* Fix failing tests

* Parse duration regardless of success

* Use len instead of equality

* Update README.md

* Include error in output

* Fix result numbering

* Update README.md

* Update api/external_endpoint.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-07-08 11:53:57 -04:00
TwiN 1855718e46 ci: Increase workflow timeouts because GHA instances seems to have gotten slower 2025-07-01 18:28:49 -04:00
TwiN d5f2d92e8e fix(ui): Explicitly omit MaximumNumberOfResults from being parsed to yaml in UI config 2025-07-01 18:04:37 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
20d1011a20 chore(deps): bump github.com/aws/aws-sdk-go from 1.55.6 to 1.55.7 (#1143)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.55.6 to 1.55.7.
- [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.6...v1.55.7)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  dependency-version: 1.55.7
  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-06-30 12:32:31 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0888094fdb chore(deps): bump modernc.org/sqlite from 1.37.0 to 1.38.0 (#1135)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.37.0 to 1.38.0.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.37.0...v1.38.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.38.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-06-29 13:39:34 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3f51536eaf chore(deps): bump golang.org/x/net from 0.40.0 to 0.41.0 (#1142)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.40.0 to 0.41.0.
- [Commits](https://github.com/golang/net/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.41.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-06-27 20:16:34 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d8a1da81f0 chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.6 to 2.52.8 (#1141)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.6 to 2.52.8.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.6...v2.52.8)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  dependency-version: 2.52.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-06-26 22:18:20 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
25b178bf94 chore(deps): bump github.com/miekg/dns from 1.1.65 to 1.1.66 (#1137)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.65 to 1.1.66.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.65...v1.1.66)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.66
  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-06-24 21:06:55 -04:00
e8e0b0f71c feat(alerting): Add ilert alert provider (#1082)
* feat(alerting): Add ilert alert provider

* fix: removed additional endpoint from Readme

* fix: removed newline

Co-authored-by: TwiN <twin@linux.com>

* fix: formatting

* fix: restAPIUrl typo

* fix: removed type field, adjusted tests

---------

Co-authored-by: TwiN <twin@linux.com>
2025-06-23 20:56:30 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
439ccaa372 chore(deps): bump github.com/prometheus/client_golang from 1.21.1 to 1.22.0 (#1136)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.21.1 to 1.22.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.21.1...v1.22.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  dependency-version: 1.22.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-06-17 20:57:12 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1bb490e068 chore(deps): bump github.com/valyala/fasthttp from 1.60.0 to 1.62.0 (#1101)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.60.0 to 1.62.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.60.0...v1.62.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.62.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-06-16 14:04:30 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b78f3f85b0 chore(deps): bump golang.org/x/crypto from 0.38.0 to 0.39.0 (#1133)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/crypto/compare/v0.38.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  dependency-version: 0.39.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-06-13 22:48:27 -04:00
TwiNandGitHub f0034f88b7 ci: Adjust codecov target 2025-06-11 15:44:07 -04:00
TwiN 659b81663e chore(alerting): Add missing typecast checks for Gotify provider 2025-06-11 15:26:08 -04:00
2f12088823 feat(alerting): Add HomeAssistant support (#1125)
* Base homeassistant alerting setup

* Update homeassistant alert documenation

* sort alert.TypeHomeAssistant to comply alphabetic policy

* Validate homeassistant as a provider interfaces implementation on compile

---------

Co-authored-by: TwiN <twin@linux.com>
2025-06-11 15:14:32 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5b666f924c chore(deps): bump google.golang.org/api from 0.228.0 to 0.236.0 (#1132)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.228.0 to 0.236.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.228.0...v0.236.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  dependency-version: 0.236.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-06-10 21:39:05 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b296d4bf4c chore(deps): bump code.gitea.io/sdk/gitea from 0.19.0 to 0.21.0 (#1084)
Bumps code.gitea.io/sdk/gitea from 0.19.0 to 0.21.0.

---
updated-dependencies:
- dependency-name: code.gitea.io/sdk/gitea
  dependency-version: 0.21.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-06-09 20:20:38 -04:00
pizzapimandGitHub 2b80b80769 docs: Add NixOS example (#1131)
* Add NixOS example

* Fix link
2025-06-08 23:59:37 -04:00
TwiNandGitHub 40c274d36a ci: Bump timeout-minutes from 60 to 120 2025-05-31 20:13:52 -04:00
TwiNandGitHub 65db65e052 revert: fix(client): Use libcap to fix icmp (#1127)
This reverts #1056

Fixes #1126
2025-05-31 19:04:50 -04:00
0a9f5d8838 fix(api): Escape endpoint key in URL (#1114)
ISSUE #1100 | Fix bug with URL encoding

Co-authored-by: Семисохин Андрей <semisohin@kinoplan.ru>
Co-authored-by: TwiN <twin@linux.com>
2025-05-31 16:22:49 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c449738844 chore(deps): bump codecov/codecov-action from 5.4.2 to 5.4.3 (#1108)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.2 to 5.4.3.
- [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.2...v5.4.3)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.4.3
  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-05-30 21:11:06 -04:00
migglandandGitHub 35985017a8 fix(alerting): Gitea issues not being closed (#1122)
Fix Gitea issues not being closed: do not ask for page 100, but pagesize (limit) 100. URL must use Index, not ID.
2025-05-28 19:11:25 -04:00
d9d5815488 fix(alerting): Support Twilio overrides for text-twilio-triggered and text-twilio-resolved (#1120)
* Make twilio alert provider translatable

* Update alerting/provider/twilio/twilio.go

---------

Co-authored-by: Imre Laszlo <imre.laszlo@matrixonline.hu>
Co-authored-by: TwiN <twin@linux.com>
2025-05-27 19:28:20 -04:00
TwiNandGitHub 04692d15ba feat: Make maximum number of results and events configurable (#1110) 2025-05-17 16:10:28 -04:00
c411b001eb perf(sqlite): Create indices to fix performance issue (#1106)
* introduce sqlite indices to fix performance issue

* Update storage/store/sql/specific_sqlite.go

---------

Co-authored-by: Gary Hodgson <gary.hodgson@tup.com>
Co-authored-by: TwiN <twin@linux.com>
2025-05-15 14:44:54 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ce1777c680 chore(deps): bump golang.org/x/net from 0.39.0 to 0.40.0 (#1098)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.39.0 to 0.40.0.
- [Commits](https://github.com/golang/net/compare/v0.39.0...v0.40.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.40.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-05-14 23:03:27 -04:00
f3a346d91c docs: Update list of characters sanitised in endpoint key (#1099)
The hash was missing.

Co-authored-by: TwiN <twin@linux.com>
2025-05-13 19:07:38 -04:00
Duarte AragãoandGitHub fca4e2170a fix(alerting): Escape custom result errors (#1095)
* escape result errors

* add specific test to check result_errors are escaped
2025-05-13 18:55:35 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b388cc87aa chore(deps): bump modernc.org/sqlite from 1.34.4 to 1.37.0 (#1062)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.34.4 to 1.37.0.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.34.4...v1.37.0)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  dependency-version: 1.37.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-05-11 23:52:39 -04:00
TwiNandGitHub fc8868d996 docs(alerting): Move misplaced note about AWS SES 2025-05-10 14:06:34 -04:00
021a28a8d9 feat(alerting): Add device support for pushover alerts (#1089)
* Add Pushover device setting

* Update device link for Pushover

* Update alerting/provider/pushover/pushover.go

* Update alerting/provider/pushover/pushover.go

---------

Co-authored-by: TwiN <twin@linux.com>
2025-05-07 18:53:08 -04:00
fe214e9e25 feat(api): Add endpoint to retrieve response time (#1070)
Add in the API the ability to get Response Times

Co-authored-by: Adrian Almenar <adrian@tecnocratica.net>
2025-04-29 17:21:18 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
493c7165fe chore(deps): bump github.com/miekg/dns from 1.1.64 to 1.1.65 (#1076)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.64 to 1.1.65.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.64...v1.1.65)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  dependency-version: 1.1.65
  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-04-28 22:15:31 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ac44c1f2d6 chore(deps): bump codecov/codecov-action from 5.4.0 to 5.4.2 (#1078)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.4.0 to 5.4.2.
- [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.0...v5.4.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  dependency-version: 5.4.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>
2025-04-21 22:01:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5212b656a2 chore(deps): bump github.com/valyala/fasthttp from 1.58.0 to 1.60.0 (#1061)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.58.0 to 1.60.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.58.0...v1.60.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  dependency-version: 1.60.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-04-13 22:14:01 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
8f0a11a9e4 chore(deps): bump golang.org/x/net from 0.37.0 to 0.39.0 (#1067)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.37.0 to 0.39.0.
- [Commits](https://github.com/golang/net/compare/v0.37.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.39.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-04-12 22:03:26 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
d576b3d72c chore(deps): bump github.com/coreos/go-oidc/v3 from 3.12.0 to 3.14.1 (#1063)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.12.0 to 3.14.1.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.12.0...v3.14.1)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  dependency-version: 3.14.1
  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-04-05 23:08:53 -04:00
33120bee52 feat(alerting): Add optional ttl parameter to pushover notifications (#1050)
Add optional ttl parameter to pushover notifications

Co-authored-by: Adrian Almenar <adrian@tecnocratica.net>
2025-04-04 20:58:16 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
53b785b581 chore(deps): bump github.com/prometheus-community/pro-bing from 0.5.0 to 0.6.1 (#1048)
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.5.0 to 0.6.1.
- [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.5.0...v0.6.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus-community/pro-bing
  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-04-03 22:46:18 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
0d11b0ef82 chore(deps): bump github.com/prometheus/client_golang from 1.20.5 to 1.21.1 (#1027)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.5 to 1.21.1.
- [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.20.5...v1.21.1)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  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-04-02 21:39:21 -04:00
8a62eb0dcc fix(client): Use libcap to fix icmp not working in containers as non-root (#1056)
Fix ping monitors not working in containers as non-root

Co-authored-by: TwiN <twin@linux.com>
2025-04-01 19:05:27 -04:00
TwiNandGitHub c9c2639f67 ci: Push to ghcr instead of DockerHub for custom tags 2025-04-01 18:28:07 -04:00
TwiN 76a8710e0b test: Revert change to maintenance test 2025-04-01 18:20:29 -04:00
TwiN d5fe682f9a test: Fix flaky maintenance test 2025-03-31 22:20:21 -04:00
TwiNandGitHub 35a3238bc9 ci: Fix feat and fix pull request title detection 2025-03-31 22:07:43 -04:00
TwiN 216dffa1a6 fix(deps): Rollback fasthttp to v1.58.0 due to overfow error
```
/go/pkg/mod/github.com/valyala/fasthttp@v1.59.0/tcplisten/tcplisten.go:187:9: math.MaxUint32 (untyped int constant 4294967295) overflows int
```
2025-03-30 13:56:48 -04:00
TwiNandGitHub 3191552343 fix(ci,deps): Bump Go version to 1.24.1 and update some dependencies (#1047) 2025-03-30 13:23:03 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fb98e853d4 chore(deps): bump github.com/valyala/fasthttp from 1.58.0 to 1.59.0 (#1044)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.58.0 to 1.59.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.58.0...v1.59.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  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-03-29 19:49:08 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e608950f98 chore(deps): bump google.golang.org/api from 0.214.0 to 0.228.0 (#1041)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.214.0 to 0.228.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.214.0...v0.228.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  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-03-28 20:18:59 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
39a623a349 chore(deps): bump golang.org/x/oauth2 from 0.25.0 to 0.28.0 (#1026)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.25.0 to 0.28.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.25.0...v0.28.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  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-03-27 22:13:10 -04:00
TwiNandGitHub 75b99d3072 fix(client): Correctly parse hostname for ICMP endpoint when using IPv6 (#1043)
Fixes #1042
2025-03-26 18:46:46 -04:00
TwiNandGitHub 55d7bcdf2e feat(ui): Implement ability to hide port from endpoint results via endpoints[].ui.hide-port (#1038)
* feat(ui): Implement ability to hide port from endpoint results via `endpoints[].ui.hide-port`

Fixes #1036

* Add test
2025-03-20 16:31:14 -04:00
TwiN b79fb09fe5 fix: Replace # by - for endpoint key 2025-03-17 20:54:36 -04:00
TwiNandGitHub 46499e13a0 fix(alerting): Ensure that alerting.incident-io.url has the correct prefix (#1034)
* fix(alerting): Ensure that alerting.incident-io.url has the correct prefix

* Fix test
2025-03-17 19:41:50 -04:00
TwiN 43b0772e7d build: Fix typo in Dockerfile 2025-03-14 19:33:27 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
6d807e322e chore(deps): bump codecov/codecov-action from 5.3.1 to 5.4.0 (#1014)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.3.1 to 5.4.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.3.1...v5.4.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  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-03-10 20:59:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
efa25c3498 chore(deps): bump golang.org/x/net from 0.33.0 to 0.37.0 (#1020)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.33.0 to 0.37.0.
- [Commits](https://github.com/golang/net/compare/v0.33.0...v0.37.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  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-03-09 20:33:50 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ee96ba8721 chore(deps): bump github.com/aws/aws-sdk-go from 1.55.5 to 1.55.6 (#971)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.55.5 to 1.55.6.
- [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.5...v1.55.6)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  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-03-08 22:11:36 -05:00
e0bdda5225 feat(ui): Allow configuring default dark-mode value (#1015)
* fix: theme flickering

* chore(ui): added dark mode tests

* feat(ui): Expose new ui.dark-mode parameter to set default theme

* refactor(ui): Rename theme variable to themeFromCookie for clarity

---------

Co-authored-by: TwiN <twin@linux.com>
Co-authored-by: TwiN <chris@twin.sh>
2025-03-07 20:32:05 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
fc07f15b67 chore(deps): bump golang.org/x/crypto from 0.31.0 to 0.36.0 (#1018)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.31.0 to 0.36.0.
- [Commits](https://github.com/golang/crypto/compare/v0.31.0...v0.36.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  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-03-07 19:02:25 -05:00
TwiNandGitHub 168dafe5bb ci(labeler): Set name to labeler 2025-03-06 22:18:12 -05:00
TwiNandGitHub ca2c6899ed ci(labeler): Don't bother checking the body 2025-03-06 21:48:42 -05:00
TwiNandGitHub da3607106c ci(labeler): Fix permission issue and continue on failure 2025-03-06 21:44:41 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e3e951aec8 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.11.0 to 3.12.0 (#1004)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.11.0 to 3.12.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.11.0...v3.12.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  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-03-06 21:38:09 -05:00
a4cb2acd24 docs: Clarify that disable-monitoring-lock is akin to enabling parallel execution (#1017)
* add keyword to doc

https://github.com/TwiN/gatus/issues/1016

* Update README.md

---------

Co-authored-by: TwiN <twin@linux.com>
2025-03-06 21:16:22 -05:00
TwiNandGitHub 467e811fd5 ci(labeler): Support area/storage label 2025-03-06 21:10:03 -05:00
TwiNandGitHub 11292b5f1e ci: Bump publish-latest's timeout-minutes 2025-03-04 19:50:02 -05:00
TwiNandGitHub 4cd35ef51d fix(dev): Add run-binary target in Makefile 2025-03-03 21:40:46 -05:00
TwiNandGitHub bb62a400f1 ci(labeler): Support pull request and issue number 2025-03-02 20:10:47 -05:00
TwiN b750fcd209 fix(dev): Add missing ENVIRONMENT=dev instruction in Makefile 2025-03-02 17:42:15 -05:00
TwiNandGitHub 62af9e842f ci: Add labeler workflow (#1010) 2025-02-21 23:39:48 -05:00
TwiNandGitHub 2049601be5 ci: Add publish-custom workflow 2025-02-18 20:59:40 -05:00
Lisovskii StanandGitHub abf5fbee9e chore(deps): Update TwiN/whois dependency to v1.1.10 (#1006)
Update whois dependency to v1.1.10
2025-02-18 20:41:18 -05:00
TwiN 8ad8a05535 docs(alerting): Add missing source-url parameter in incident-io provider 2025-02-17 15:09:46 -05:00
TwiN 3f94e16950 docs(alerting): Fix formatting in incident-io section 2025-02-17 15:04:19 -05:00
TwiNandGitHub 46b24b849f feat(dns): Support SRV query type (#1005) 2025-02-17 10:22:44 -05:00
a1f7bd7b73 feat(maintenance): Per-endpoint maintenance configuration (#982)
* feat: add endpoint.maintenance-windows array for per-endpoint maintenance configuration

* doc: initial entry for maintenance windows in endpoint config

* doc: example documentation for per-endpoint configuration of maintenance
windows

* chore: var => :=

* test: add checks for maintenance window defaults in endpoint configuration

* chore: clean up new-lines

---------

Co-authored-by: TwiN <twin@linux.com>
2025-02-16 23:05:13 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
7e122a9fd9 chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.5 to 2.52.6 (#952)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.5 to 2.52.6.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.5...v2.52.6)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  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-02-16 22:55:53 -05:00
TwiN 52c142ec81 ci(test-ui): Fix push trigger 2025-02-10 21:47:06 -05:00
TwiNandGitHub 18e90d0e63 fix(ui): Don't override body/html background-color (#1002)
* fix(ui): Don't override body/html background-color

* fix(ui): Don't override body/html background-color
2025-02-10 21:45:44 -05:00
Tore Stendal LønøyandGitHub 64b4c53b4e feat(ui): expose 30d badges and response chart selector for duration (#994)
* chore: initial commit for 30d badge/chart UI support

* chore revert some changes

* chore build frontend

* chore remove old line

* rebuild frontend

* re-order list

* feat: add support for 1h response chart

* chore(docs): add section about response time chart

* chore(frontend): add missing select

* chore: code format

* chore: new web build

* Revert "chore: code format"

This reverts commit 517f0ce3c8.

* chore(responsechart): remove support for 1h charts

response time is only stored at 1h intervals, having a chart for the past 1h does not make sense.
2025-02-10 20:55:19 -05:00
TwiN 9fd134ca9c ci: Don't comment on pull requests 2025-02-08 11:52:31 -05:00
TwiN d9e0ee04f9 ci: Run test-ui workflow on push event as well 2025-02-06 19:36:54 -05:00
TwiNandGitHub 5227da3407 ci: Add workflow to test UI changes (#998)
* ci: Add workflow to test UI changes

* test

* remove test
2025-02-06 19:33:37 -05:00
541a70584d feat(alerting): Add Incident.io alerting provider (#972)
* feat(alerting): added incident.io provider alerting.

* Tests: added incident.io provider unit tests.

* Documentation: added incidentio documentation.

* Refactor: Changed documentation + types to an alphabetical order.

* Refactor: change wrong comment.

Co-authored-by: Maksim Zhylinski <maksim@zhylinski.me>

* Update README.md

Co-authored-by: Maksim Zhylinski <maksim@zhylinski.me>

* Update alerting/provider/incidentio/incident_io.go

Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>

* Update alerting/provider/incidentio/incident_io.go

Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>

* Update alerting/provider/incidentio/incident_io.go

Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>

* Update alerting/provider/incidentio/incident_io.go

Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>

* Refactor: changed alertSourceID to url.

* Refactor: changed documentation.

* Refactor: refactored tests, removed status from config.

* Readme: updated docs.

* Refactor: removed duplication key in favor of ResolveKey.

* Refactor: change variable format.

* Feat + Test: added support for passing metadata and source url, added resolved-with-metadata-source-url test case.

* Refactor: chaned variable naming

* Update alerting/config.go

* Update README.md

* Update README.md

* Update README.md

* Apply suggestions from code review

* Refactor: sort var by abc

---------

Co-authored-by: Maksim Zhylinski <maksim@zhylinski.me>
Co-authored-by: Julien Limoges <limoges@users.noreply.github.com>
Co-authored-by: TwiN <twin@linux.com>
2025-02-06 19:21:35 -05:00
ea2b7c4bdf fix(remote): Support partial remote instance failure (#990)
Failing remotes allowed and logged

Co-authored-by: Oleh Rakovych <oleh@amberoad.ai>
2025-02-05 22:04:13 -05:00
Victor LaborieandGitHub 9d8928dee0 fix(config): Ignore config files with double dot in it (#696) (#984) 2025-02-02 20:37:55 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
41085757f9 chore(deps): bump codecov/codecov-action from 5.1.2 to 5.3.1 (#980)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.2 to 5.3.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.1.2...v5.3.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  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-02-01 13:58:45 -05:00
Bo-Yi WuandGitHub 5b3e0c8074 refactor: periodic operations to use time.Ticker (#978)
- Use `time.Ticker` instead of `time.After` for periodic operations in `autoSave` function
- Use `time.Ticker` instead of `time.After` for periodic operations in `monitor` function

Signed-off-by: appleboy <appleboy.tw@gmail.com>
2025-01-28 22:23:16 -05:00
Alex MarasandGitHub 975ac3592e fix(maintenance): timezone handling offset issue (#981)
* fix: uses time.Date to construct time at midnight in Timezone instead of Truncate

* test: check for Perth validity using adjusted timezone handling

* chore: remove newlines around day adjustment block

* chore: remove separate define of dayWhereMaintenancePeriodWouldStart
2025-01-28 22:15:35 -05:00
TwiN dd839be918 chore(logging): Fix typo in log
relates to #688
2025-01-20 18:28:34 -05:00
TwiN 1c99386807 fix(logging): Add debug log when skipping alert due to send-on-resolved: false
relates to #688
2025-01-20 18:14:59 -05:00
RaniandGitHub fa3e5dcc6e feat(ssh): Support authless SSH health check (#956)
* Feature + Test +  Documentation: added no-auth ssh health cheack feature, changed documentation to fit new behavior, added ssh test cases.

* Refactor: refactored authenticate field to infer from username and password insted of specifying it inside config.

* Refactor: removed non used field.

* Refactor: changed error, removed spaces.

* Refactor: added comments.
2025-01-19 16:22:41 -05:00
Devin BuhlandGitHub 0bba77ab2b feat(metrics): add gatus_results_endpoint_success (#970)
* feat(metrics): add gatus_endpoint_success

Signed-off-by: Devin Buhl <devin@buhl.casa>

* feat(metrics): add gatus_endpoint_success

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

* chore: update readme

Signed-off-by: Devin Buhl <devin@buhl.casa>

---------

Signed-off-by: Devin Buhl <devin@buhl.casa>
2025-01-19 16:12:52 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
31073365ed chore(deps): bump github.com/prometheus-community/pro-bing from 0.4.0 to 0.5.0 (#949)
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.4.0 to 0.5.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.4.0...v0.5.0)

---
updated-dependencies:
- dependency-name: github.com/prometheus-community/pro-bing
  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-01-19 00:30:21 -05:00
TwiNandGitHub 69dbe4fa23 test: Fix flaky DNS tests that check for valid IP (#968) 2025-01-18 20:26:25 -05:00
9157b5bf67 fix(client): Potential channel blocking issue for SCTP endpoints (#962)
fixing channel blocking in client.go

Co-authored-by: TwiN <twin@linux.com>
2025-01-13 23:30:15 -05:00
1ddaf5f3e5 fix(alerting): replace HTML entities with emojis in teams-workflows provider to prevent rendering issues (#941)
* replace html entities with emojis to prevent rendering issues

* update teamsworklfows tests as well

---------

Co-authored-by: Sascha Voth <voth@jacob.de>
Co-authored-by: TwiN <twin@linux.com>
2025-01-13 23:18:47 -05:00
TwiNandGitHub e1675cc747 docs: Fix test badge 2025-01-13 23:04:45 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
47246ddcf7 chore(deps): bump golang.org/x/oauth2 from 0.24.0 to 0.25.0 (#959)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.24.0 to 0.25.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.24.0...v0.25.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  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-01-12 21:19:22 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c259364edf chore(deps): bump modernc.org/sqlite from 1.34.2 to 1.34.4 (#948)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.34.2 to 1.34.4.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.34.2...v1.34.4)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  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-01-07 22:23:37 -05:00
tigattackandGitHub b650518ccc ci: Add image labels, merge publish to Docker Hub/GHCR workflows (#954)
* Merge publish to Docker Hub/GHCR workflows

* Add labels to images built by publish workflows
2025-01-05 23:03:14 -05:00
TwiNandGitHub d69844dc13 docs: Use stable tag in quick start instructions 2024-12-29 10:07:03 -05:00
TwiNandGitHub 8aae277113 Revert "feat(metrics): Add URL to metrics labels" (#947)
Revert "feat(metrics): Add URL to metrics labels (#875)"

This reverts commit e88f47f0f4.
2024-12-28 16:48:00 -05:00
c44d998fb3 feat(api): Expose uptime data as text via API (#758)
* Expose Raw Uptime Data via API

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Add Test for Raw Uptime Data API Endpoint

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Document Raw Uptime Data API Endpoint

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Fix Test after #759 Core Refactor

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Update Raw Data Content Type

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Support 30d Data from Raw Uptime Endpoint

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Update README.md

* Update README.md

---------

Signed-off-by: James Hillyard <james.hillyard@payara.fish>
Co-authored-by: TwiN <twin@linux.com>
2024-12-28 15:59:28 -05:00
e88f47f0f4 feat(metrics): Add URL to metrics labels (#875)
Co-authored-by: TwiN <twin@linux.com>
2024-12-28 14:07:50 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
19713c2d79 chore(deps): bump github.com/prometheus/client_golang from 1.20.4 to 1.20.5 (#923)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.20.4 to 1.20.5.
- [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.20.4...v1.20.5)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  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>
2024-12-28 14:01:09 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
6eca0201ce chore(deps): bump github.com/valyala/fasthttp from 1.56.0 to 1.58.0 (#940)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.56.0 to 1.58.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/v1.56.0...v1.58.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  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>
2024-12-28 13:39:22 -05:00
f2a82e0681 feat(alerting): Add condition results to Pushover (#945)
Co-authored-by: TwiN <twin@linux.com>
2024-12-28 11:35:19 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
9a12adbaf5 chore(deps): bump google.golang.org/api from 0.209.0 to 0.214.0 (#939)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.209.0 to 0.214.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.209.0...v0.214.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  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>
2024-12-28 11:28:58 -05:00
TwiNandGitHub efbb739a44 feat(ui): Implement Custom CSS configuration (#943)
* feat(ui): Implement Custom CSS configuration

* Update web/app/public/index.html
2024-12-26 23:08:16 -05:00
Paul NikonowiczandGitHub 78c9a1bd41 test(client): add test for insecure flag (#942)
adds tests to complement work done in
https://github.com/TwiN/gatus/pull/547

it was rumored that tls insecure was not working,
although tls insecure appears to be working fine,
no test was found originally.

this adds a test to ensure that tls insecure works
as expected. the domain badssl.com is used to
verify that the insecure flag works as expected
2024-12-26 22:56:36 -05:00
Ikko Eltociear AshimineandGitHub 0440deb6b5 docs: Fix typo in README (#946)
docs: update README.md

`endpoints[].ui.badge.reponse-time` -> `endpoints[].ui.badge.response-time`
2024-12-25 14:11:02 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
83a555e334 chore(deps): bump github.com/TwiN/deepmerge from 0.2.1 to 0.2.2 (#922)
Bumps [github.com/TwiN/deepmerge](https://github.com/TwiN/deepmerge) from 0.2.1 to 0.2.2.
- [Release notes](https://github.com/TwiN/deepmerge/releases)
- [Commits](https://github.com/TwiN/deepmerge/compare/v0.2.1...v0.2.2)

---
updated-dependencies:
- dependency-name: github.com/TwiN/deepmerge
  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>
2024-12-19 18:24:05 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
fcb4c71cda chore(deps): bump codecov/codecov-action from 5.1.1 to 5.1.2 (#935)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.1.1 to 5.1.2.
- [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.1.1...v5.1.2)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  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>
2024-12-19 18:23:39 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5169c1a91a chore(deps): bump golang.org/x/net from 0.31.0 to 0.33.0 (#936)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.31.0 to 0.33.0.
- [Commits](https://github.com/golang/net/compare/v0.31.0...v0.33.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  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>
2024-12-19 18:23:20 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
126b78fc0e chore(deps): bump codecov/codecov-action from 5.0.7 to 5.1.1 (#926)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 5.0.7 to 5.1.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.0.7...v5.1.1)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  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>
2024-12-17 23:45:12 -05:00
TwiN 8b2cd2e4b3 docs: Improve alerting documentation 2024-12-17 21:23:19 -05:00
TwiNandGitHub 79c9f24c15 feat(alerting): Implement alert-level provider overrides (#929)
* feat(alerting): Implement alert-level provider overrides

Fixes #96

* Fix tests

* Add missing test cases for alerting providers

* feat(alerting): Implement alert-level overrides on all providers

* chore: Add config.yaml to .gitignore

* fix typo in discord provider

* test: Start fixing tests for alerting providers

* test: Fix GitLab tests

* Fix all tests

* test: Improve coverage

* test: Improve coverage

* Rename override to provider-override

* docs: Mention new provider-override config

* test: Improve coverage

* test: Improve coverage

* chore: Rename Alert.OverrideAsBytes to Alert.ProviderOverrideAsBytes
2024-12-16 20:32:13 -05:00
be9ae6f55d docs: Fix typo (#931)
chore: typo fix

Co-authored-by: TwiN <twin@linux.com>
2024-12-14 13:28:57 -05:00
Philip WhitesideandGitHub 539c7635a1 docs: Change protocol backslash (\\) to forwardslash (//) in README.md (#933)
Change protocol backslash to forwardslash in README.md

Some of the text examples used `proto:\\` instead of `proto://`. The backslash is not correct and results in "panic: invalid endpoint testgroup-|-testendpoint: unknown endpoint type".
2024-12-14 08:20:47 -05:00
TwiN d56a8487eb refactor: Rename HasTlsConfig to HasTLSConfig 2024-12-06 21:35:26 -05:00
TwiN a1bbc41b62 chore: Remove unnecessary newline 2024-12-06 21:34:44 -05:00
TwiN cdac5790cb docs: Fix typo in comment 2024-12-06 21:34:13 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ff318efb67 chore(deps): bump modernc.org/sqlite from 1.33.1 to 1.34.2 (#921)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.33.1 to 1.34.2.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.33.1...v1.34.2)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  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>
2024-12-05 01:05:12 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
504bd8f66c chore(deps): bump google.golang.org/api from 0.183.0 to 0.209.0 (#912)
Bumps [google.golang.org/api](https://github.com/googleapis/google-api-go-client) from 0.183.0 to 0.209.0.
- [Release notes](https://github.com/googleapis/google-api-go-client/releases)
- [Changelog](https://github.com/googleapis/google-api-go-client/blob/main/CHANGES.md)
- [Commits](https://github.com/googleapis/google-api-go-client/compare/v0.183.0...v0.209.0)

---
updated-dependencies:
- dependency-name: google.golang.org/api
  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>
2024-12-03 23:12:25 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
917ad59306 chore(deps): bump github.com/aws/aws-sdk-go from 1.54.10 to 1.55.5 (#890)
Bumps [github.com/aws/aws-sdk-go](https://github.com/aws/aws-sdk-go) from 1.54.10 to 1.55.5.
- [Release notes](https://github.com/aws/aws-sdk-go/releases)
- [Commits](https://github.com/aws/aws-sdk-go/compare/v1.54.10...v1.55.5)

---
updated-dependencies:
- dependency-name: github.com/aws/aws-sdk-go
  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>
2024-12-03 19:13:57 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
f04dbfa516 chore(deps): bump golang.org/x/net from 0.29.0 to 0.31.0 (#894)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.29.0 to 0.31.0.
- [Commits](https://github.com/golang/net/compare/v0.29.0...v0.31.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  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>
2024-12-03 19:12:35 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
4e6e57747f chore(deps): bump codecov/codecov-action from 4.6.0 to 5.0.7 (#910)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.6.0 to 5.0.7.
- [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/v4.6.0...v5.0.7)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  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>
2024-12-03 19:04:46 -05:00
778019590d feat(alerting): Add overrides for Ntfy provider (#918)
* Add overrides to Ntfy alert provider

* Update alerting/provider/ntfy/ntfy.go

---------

Co-authored-by: TwiN <twin@linux.com>
2024-11-30 22:09:20 -05:00
29cbff6774 feat(client): Add support for PTR DNS records (#886)
Co-authored-by: TwiN <twin@linux.com>
2024-11-28 18:21:44 -05:00
TwiN 2fe991319e docs: Remove duplicate "Proxy client configuration" section 2024-11-19 20:45:54 -05:00
52a73125f9 feat(alerting): Update Adaptive Cards design for teams-workflows (#903)
Update adaptive cards design for msteams

Co-authored-by: Tim-orius <timorius@gmx.de>
2024-11-19 20:24:17 -05:00
TwiNandGitHub 01131755bc fix(logging): Replace log-level parameter by GATUS_LOG_LEVEL env var (#895)
* fix(logging): Replace log-level parameter by GATUS_LOG_LEVEL env var

* Improve log message if GATUS_LOG_LEVEL isn't set
2024-11-13 23:54:00 -05:00
8060a77b1f feat(logging): Allow configuring logging verbosity level (#872)
* introduces TwiN/logr library

* use new features of logr library

* minor tweaks and formatting

* Apply suggestions from code review

---------

Co-authored-by: TwiN <twin@linux.com>
2024-11-13 19:02:53 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
92bb42d444 chore(deps): bump golang.org/x/oauth2 from 0.23.0 to 0.24.0 (#893)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.23.0 to 0.24.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.23.0...v0.24.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  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>
2024-11-12 20:33:13 -05:00
TwiNandGitHub c758af8791 docs: Fix order of alerting config params 2024-11-10 17:48:30 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>TwiN
0d38132a15 chore(deps): bump golang.org/x/oauth2 from 0.21.0 to 0.23.0 (#891)
Bumps [golang.org/x/oauth2](https://github.com/golang/oauth2) from 0.21.0 to 0.23.0.
- [Commits](https://github.com/golang/oauth2/compare/v0.21.0...v0.23.0)

---
updated-dependencies:
- dependency-name: golang.org/x/oauth2
  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>
2024-11-07 23:53:46 -05:00
177feba75b feat(pushover): priority on resolved (#879)
* feat(pushover): priority on resolved

Signed-off-by: Devin Buhl <devin@buhl.casa>

* Update README.md

* Update README.md

* Rename ResolvedPriority

* Update README.md

* Update alerting/provider/pushover/pushover.go

* Update README.md

* Update pushover.go

* Update pushover_test.go

* fix: update tests

Signed-off-by: Devin Buhl <devin@buhl.casa>

* fix: update tests

Signed-off-by: Devin Buhl <devin@buhl.casa>

---------

Signed-off-by: Devin Buhl <devin@buhl.casa>
Co-authored-by: TwiN <twin@linux.com>
2024-11-05 19:57:33 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c6ff6ec583 chore(deps): bump github.com/gofiber/fiber/v2 from 2.52.4 to 2.52.5 (#888)
Bumps [github.com/gofiber/fiber/v2](https://github.com/gofiber/fiber) from 2.52.4 to 2.52.5.
- [Release notes](https://github.com/gofiber/fiber/releases)
- [Commits](https://github.com/gofiber/fiber/compare/v2.52.4...v2.52.5)

---
updated-dependencies:
- dependency-name: github.com/gofiber/fiber/v2
  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>
2024-11-04 18:52:17 -05:00
TwiNandGitHub e061c788de ci: Update open-pull-requests-limit from 1 to 3 2024-11-04 18:37:31 -05:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
e537758590 chore(deps): bump github.com/coreos/go-oidc/v3 from 3.10.0 to 3.11.0 (#870)
Bumps [github.com/coreos/go-oidc/v3](https://github.com/coreos/go-oidc) from 3.10.0 to 3.11.0.
- [Release notes](https://github.com/coreos/go-oidc/releases)
- [Commits](https://github.com/coreos/go-oidc/compare/v3.10.0...v3.11.0)

---
updated-dependencies:
- dependency-name: github.com/coreos/go-oidc/v3
  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>
2024-11-04 18:34:33 -05:00
Martin StoneandGitHub 1c78f659c2 docs: Fix typo in README (#885) 2024-10-31 20:29:48 -04:00
Liam JonesandGitHub 40cbb4b1d4 fix(alerting): Add missing support for default-alert on teams-workflows (#883)
Fix default-alert on teams-workflows
2024-10-31 19:59:29 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
7103d0a9ae chore(deps): bump codecov/codecov-action from 4.5.0 to 4.6.0 (#873)
Bumps [codecov/codecov-action](https://github.com/codecov/codecov-action) from 4.5.0 to 4.6.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/v4.5.0...v4.6.0)

---
updated-dependencies:
- dependency-name: codecov/codecov-action
  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>
2024-10-21 21:50:03 -04:00
ff4b09dff8 feat(alerting): Implement new Teams Workflow alert (#847)
* POC Teams Workflow Alerting

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Document Teams Workflow Alert

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Rename 'teamsworkflow' to 'teams-workflows'

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Fix README Table Format

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

* Fix Test to Expect Correct Emoji

Signed-off-by: James Hillyard <james.hillyard@payara.fish>

---------

Signed-off-by: James Hillyard <james.hillyard@payara.fish>
Co-authored-by: TwiN <twin@linux.com>
2024-10-15 17:25:02 -04:00
Simon ErhardtandGitHub 29072da23e docs: change the url of the helm chart to your repo (#878)
fix: change the url of the helm chart to your repo
2024-10-14 11:27:37 -04:00
stendlerandGitHub bb973979d2 feat(alerting): add email and click action to ntfy provider (#778)
* feat(alerting): add optional email to ntfy provider

https://docs.ntfy.sh/publish/#e-mail-notifications

* feat(alerting): add optional click action to ntfy provider

https://docs.ntfy.sh/publish/#click-action

* feat(alerting): add option to disable firebase in ntfy provider

https://docs.ntfy.sh/publish/#disable-firebase

* feat(alerting): add option to disable message caching in ntfy provider

https://docs.ntfy.sh/publish/#message-caching

* test(alerting): add buildRequestBody tests for email and click properties

* test(alerting): add tests for Send to verify request headers

* feat(alerting): refactor to prefix firebase & cache with "disable"

This avoids the need for a pointer, as omitting these bools in the config defaults to false
and omitting to set these headers will use the server's default - which is enabled on ntfy.sh
2024-10-04 20:23:07 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3a7be5caff chore(deps): bump github.com/valyala/fasthttp from 1.54.0 to 1.56.0 (#869)
Bumps [github.com/valyala/fasthttp](https://github.com/valyala/fasthttp) from 1.54.0 to 1.56.0.
- [Release notes](https://github.com/valyala/fasthttp/releases)
- [Commits](https://github.com/valyala/fasthttp/compare/1.54.0...v1.56.0)

---
updated-dependencies:
- dependency-name: github.com/valyala/fasthttp
  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>
2024-09-25 23:57:19 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bd1a544cf7 chore(deps): bump github.com/prometheus/client_golang from 1.19.1 to 1.20.4 (#867)
chore(deps): bump github.com/prometheus/client_golang

Bumps [github.com/prometheus/client_golang](https://github.com/prometheus/client_golang) from 1.19.1 to 1.20.4.
- [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.19.1...v1.20.4)

---
updated-dependencies:
- dependency-name: github.com/prometheus/client_golang
  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>
2024-09-23 20:43:27 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
aa51b0ad70 chore(deps): bump github.com/miekg/dns from 1.1.61 to 1.1.62 (#866)
Bumps [github.com/miekg/dns](https://github.com/miekg/dns) from 1.1.61 to 1.1.62.
- [Changelog](https://github.com/miekg/dns/blob/master/Makefile.release)
- [Commits](https://github.com/miekg/dns/compare/v1.1.61...v1.1.62)

---
updated-dependencies:
- dependency-name: github.com/miekg/dns
  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>
2024-09-22 21:39:41 -04:00
fc09981779 feat(alerting): alert teams add client insecure option (#864)
* feat: alert teams add client insecure option

* feat(docs): add comment for `ClientConfig` field

* fix typo

---------

Co-authored-by: raojinlin <raojinlin302@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2024-09-21 20:26:30 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
de2d41ed88 chore(deps): bump github.com/wcharczuk/go-chart/v2 from 2.1.1 to 2.1.2 (#862)
Bumps [github.com/wcharczuk/go-chart/v2](https://github.com/wcharczuk/go-chart) from 2.1.1 to 2.1.2.
- [Release notes](https://github.com/wcharczuk/go-chart/releases)
- [Commits](https://github.com/wcharczuk/go-chart/compare/v2.1.1...v2.1.2)

---
updated-dependencies:
- dependency-name: github.com/wcharczuk/go-chart/v2
  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>
2024-09-19 22:15:21 -04:00
59842d5e88 feat(alerting): custom alert support endpoint errors (#844)
* feat(alerting): add support for including endpoint errors in custom alerts

- Updated `buildHTTPRequest` method in `AlertProvider` to accept a `result` parameter.
- Added support for including `[ENDPOINT_ERRORS]` in both the request body and URL, which will be replaced by the errors from `Result.Errors[]`.
- Adjusted `CreateExternalEndpointResult` to capture and store errors from query parameters.
- This allows custom alerts to include detailed error information, enhancing the flexibility of alert notifications.

* feat: add ENDPOINT_ERRORS example

* feat: add tests

* Refactor: code review feedback

* delete unsed errors

* Update README.md

* Apply suggestions from code review

---------

Co-authored-by: raojinlin <raojinlin302@gmail.com>
Co-authored-by: TwiN <twin@linux.com>
2024-09-17 22:26:21 -04:00
b0c2f1eba9 feat(alerting): Add optional field alerting.mattermost.channel (#823)
* Add optional field alerting.mattermost.channel

* Added omitempty to channel in Body for Mattermost

* Update README.md

* Update alerting/provider/mattermost/mattermost.go

* Expose channel configuration parameter through alerting.mattermost.channel

---------

Co-authored-by: Gerrit <gerrit.abma@logius.nl>
Co-authored-by: TwiN <twin@linux.com>
2024-09-17 21:02:31 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
f55f39fa33 chore(deps): bump golang.org/x/net from 0.26.0 to 0.29.0 (#861)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.26.0 to 0.29.0.
- [Commits](https://github.com/golang/net/compare/v0.26.0...v0.29.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  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>
2024-09-17 19:48:51 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
1a0c0af403 chore(deps): bump modernc.org/sqlite from 1.31.1 to 1.33.1 (#860)
Bumps [modernc.org/sqlite](https://gitlab.com/cznic/sqlite) from 1.31.1 to 1.33.1.
- [Commits](https://gitlab.com/cznic/sqlite/compare/v1.31.1...v1.33.1)

---
updated-dependencies:
- dependency-name: modernc.org/sqlite
  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>
2024-09-16 19:04:13 -04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
12b04802ef chore(deps): bump golang.org/x/crypto from 0.24.0 to 0.27.0 (#857)
Bumps [golang.org/x/crypto](https://github.com/golang/crypto) from 0.24.0 to 0.27.0.
- [Commits](https://github.com/golang/crypto/compare/v0.24.0...v0.27.0)

---
updated-dependencies:
- dependency-name: golang.org/x/crypto
  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>
2024-09-11 21:15:34 -04:00
d947a6b6f5 feat(alerting): Implement Zulip's alerts (#845)
* feat(alerting): Add alert type for Zulip

* feat(alerting): Implement Zulip alert provider

* feat(alerting): Add Zulip to alerting/config.go

* docs: Add Zulip alerts to README.md

* fix(alerting): Include alert description in message

* fix(alerting): validate Zuilip interface on compile

* chore(alerting): fix import order

* fix(alerting): rename ChannelId to ChannelID

* Update alerting/provider/zulip/zulip_test.go

---------

Co-authored-by: TwiN <twin@linux.com>
2024-09-03 23:21:08 -04:00
191 changed files with 15745 additions and 5441 deletions
File diff suppressed because it is too large Load Diff
@@ -1,5 +1,4 @@
metrics: true metrics: true
debug: false
ui: ui:
header: Example Company header: Example Company
link: https://example.org link: https://example.org
+4
View File
@@ -0,0 +1,4 @@
# NixOS
Gatus is implemented as a NixOS module. See [gatus.nix](./gatus.nix) for example
usage.
+23
View File
@@ -0,0 +1,23 @@
{
services.gatus = {
enable = true;
settings = {
web.port = 8080;
endpoints = [
{
name = "website";
url = "https://twin.sh/health";
interval = "5m";
conditions = [
"[STATUS] == 200"
"[BODY].status == UP"
"[RESPONSE_TIME] < 300"
];
}
];
};
};
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 536 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

+2 -2
View File
@@ -1,12 +1,12 @@
ignore: ignore:
- "watchdog/watchdog.go" - "watchdog/watchdog.go"
- "storage/store/sql/specific_postgres.go" # Can't test for postgres - "storage/store/sql/specific_postgres.go" # Can't test for postgres
comment: false
coverage: coverage:
status: status:
patch: off patch: off
project: project:
default: default:
target: 75% target: 70%
threshold: null threshold: null
+1 -1
View File
@@ -7,7 +7,7 @@ updates:
interval: "daily" interval: "daily"
- package-ecosystem: "gomod" - package-ecosystem: "gomod"
directory: "/" directory: "/"
open-pull-requests-limit: 1 open-pull-requests-limit: 3
labels: ["dependencies"] labels: ["dependencies"]
schedule: schedule:
interval: "daily" interval: "daily"
+3 -3
View File
@@ -18,13 +18,13 @@ jobs:
build: build:
name: benchmark name: benchmark
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 5 timeout-minutes: 15
steps: steps:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: 1.22.2 go-version: 1.24.1
repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}" repository: "${{ github.event.inputs.repository || 'TwiN/gatus' }}"
ref: "${{ github.event.inputs.ref || 'master' }}" ref: "${{ github.event.inputs.ref || 'master' }}"
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Benchmark - name: Benchmark
run: go test -bench=. ./storage/store run: go test -bench=. ./storage/store
+46
View File
@@ -0,0 +1,46 @@
name: labeler
on:
pull_request_target:
types:
- opened
issues:
types:
- opened
jobs:
labeler:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
issues: write
pull-requests: write
steps:
- name: Label
continue-on-error: true
env:
TITLE: ${{ github.event.issue.title }}${{ github.event.pull_request.title }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GH_REPO: ${{ github.repository }}
NUMBER: ${{ github.event.issue.number }}${{ github.event.pull_request.number }}
run: |
if [[ $TITLE == "feat"* ]]; then
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
if [[ $TITLE == *"security"* || $TITLE == *"oidc"* || $TITLE == *"oauth2"* ]]; then
gh issue edit "$NUMBER" --add-label "area/security"
fi
if [[ $TITLE == *"metric"* || $TITLE == *"prometheus"* ]]; then
gh issue edit "$NUMBER" --add-label "area/metrics"
fi
+40
View File
@@ -0,0 +1,40 @@
name: publish-custom
run-name: "${{ inputs.tag }}"
on:
workflow_dispatch:
inputs:
tag:
description: Custom tag to publish
jobs:
publish-custom:
runs-on: ubuntu-latest
timeout-minutes: 60
steps:
- uses: actions/checkout@v5
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get image repository
run: echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=${{ inputs.tag }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64
pull: true
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+11 -3
View File
@@ -3,9 +3,9 @@ on: [workflow_dispatch]
jobs: jobs:
publish-experimental: publish-experimental:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 20 timeout-minutes: 60
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
@@ -17,10 +17,18 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.IMAGE_REPOSITORY }}
tags: |
type=raw,value=experimental
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64 platforms: linux/amd64
pull: true pull: true
push: true push: true
tags: ${{ env.IMAGE_REPOSITORY }}:experimental tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -1,38 +0,0 @@
name: publish-latest-to-ghcr
on:
workflow_run:
workflows: [test]
branches: [master]
types: [completed]
concurrency:
group: ${{ github.event.workflow_run.head_repository.full_name }}::${{ github.event.workflow_run.head_branch }}::${{ github.workflow }}
cancel-in-progress: true
jobs:
publish-latest-to-ghcr:
runs-on: ubuntu-latest
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
permissions:
contents: read
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest
+22 -4
View File
@@ -11,24 +11,42 @@ jobs:
publish-latest: publish-latest:
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }} if: ${{ (github.event.workflow_run.conclusion == 'success') && (github.event.workflow_run.head_repository.full_name == github.repository) }}
timeout-minutes: 60 timeout-minutes: 240
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Get image repository - name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV run: |
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Login to Docker Registry - name: Login to Docker Registry
uses: docker/login-action@v3 uses: docker/login-action@v3
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_REPOSITORY }}
${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=latest
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: ${{ env.IMAGE_REPOSITORY }}:latest tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
@@ -1,37 +0,0 @@
name: publish-release-to-ghcr
on:
release:
types: [published]
jobs:
publish-release-to-ghcr:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true
push: true
tags: |
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest
+24 -7
View File
@@ -6,15 +6,17 @@ jobs:
publish-release: publish-release:
name: publish-release name: publish-release
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 60 timeout-minutes: 240
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Get image repository - name: Get image repository
run: echo IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV run: |
echo DOCKER_IMAGE_REPOSITORY=$(echo ${{ secrets.DOCKER_USERNAME }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
echo GHCR_IMAGE_REPOSITORY=$(echo ghcr.io/${{ github.actor }}/${{ github.event.repository.name }} | tr '[:upper:]' '[:lower:]') >> $GITHUB_ENV
- name: Get the release - name: Get the release
run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV run: echo RELEASE=${GITHUB_REF/refs\/tags\//} >> $GITHUB_ENV
- name: Login to Docker Registry - name: Login to Docker Registry
@@ -22,13 +24,28 @@ jobs:
with: with:
username: ${{ secrets.DOCKER_USERNAME }} username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }} password: ${{ secrets.DOCKER_PASSWORD }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: |
${{ env.DOCKER_IMAGE_REPOSITORY }}
${{ env.GHCR_IMAGE_REPOSITORY }}
tags: |
type=raw,value=${{ env.RELEASE }}
type=raw,value=stable
type=raw,value=latest
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
platforms: linux/amd64,linux/arm/v7,linux/arm64 platforms: linux/amd64,linux/arm/v7,linux/arm64
pull: true pull: true
push: true push: true
tags: | tags: ${{ steps.meta.outputs.tags }}
${{ env.IMAGE_REPOSITORY }}:${{ env.RELEASE }} labels: ${{ steps.meta.outputs.labels }}
${{ env.IMAGE_REPOSITORY }}:stable
${{ env.IMAGE_REPOSITORY }}:latest
+18
View File
@@ -0,0 +1,18 @@
name: test-ui
on:
pull_request:
paths:
- 'web/**'
push:
branches:
- master
paths:
- 'web/**'
jobs:
test-ui:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v5
- run: make frontend-install-dependencies
- run: make frontend-build
+4 -4
View File
@@ -14,12 +14,12 @@ on:
jobs: jobs:
test: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
timeout-minutes: 10 timeout-minutes: 30
steps: steps:
- uses: actions/setup-go@v5 - uses: actions/setup-go@v5
with: with:
go-version: 1.22.2 go-version: 1.24.1
- uses: actions/checkout@v4 - uses: actions/checkout@v5
- name: Build binary to make sure it works - name: Build binary to make sure it works
run: go build run: go build
- name: Test - name: Test
@@ -28,7 +28,7 @@ jobs:
# was configured by the "Set up Go" step (otherwise, it'd use sudo's "go" executable) # 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 run: sudo env "PATH=$PATH" "GOROOT=$GOROOT" go test ./... -race -coverprofile=coverage.txt -covermode=atomic
- name: Codecov - name: Codecov
uses: codecov/codecov-action@v4.5.0 uses: codecov/codecov-action@v5.4.3
with: with:
files: ./coverage.txt files: ./coverage.txt
token: ${{ secrets.CODECOV_TOKEN }} token: ${{ secrets.CODECOV_TOKEN }}
+2 -1
View File
@@ -17,4 +17,5 @@ node_modules
*.db-shm *.db-shm
*.db-wal *.db-wal
gatus gatus
config/config.yml config/config.yml
config.yaml
+4 -2
View File
@@ -1,5 +1,5 @@
# Build the go application into a binary # Build the go application into a binary
FROM golang:alpine as builder FROM golang:alpine AS builder
RUN apk --update add ca-certificates RUN apk --update add ca-certificates
WORKDIR /app WORKDIR /app
COPY . ./ COPY . ./
@@ -15,6 +15,8 @@ FROM scratch
COPY --from=builder /app/gatus . COPY --from=builder /app/gatus .
COPY --from=builder /app/config.yaml ./config/config.yaml COPY --from=builder /app/config.yaml ./config/config.yaml
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
ENV PORT=8080 ENV GATUS_CONFIG_PATH=""
ENV GATUS_LOG_LEVEL="INFO"
ENV PORT="8080"
EXPOSE ${PORT} EXPOSE ${PORT}
ENTRYPOINT ["/gatus"] ENTRYPOINT ["/gatus"]
+8 -1
View File
@@ -6,7 +6,11 @@ install:
.PHONY: run .PHONY: run
run: run:
GATUS_CONFIG_PATH=./config.yaml ./$(BINARY) ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml go run main.go
.PHONY: run-binary
run-binary:
ENVIRONMENT=dev GATUS_CONFIG_PATH=./config.yaml ./$(BINARY)
.PHONY: clean .PHONY: clean
clean: clean:
@@ -34,6 +38,9 @@ docker-build-and-run: docker-build docker-run
# Front end # # Front end #
############# #############
frontend-install-dependencies:
npm --prefix web/app install
frontend-build: frontend-build:
npm --prefix web/app run build npm --prefix web/app run build
+701 -239
View File
File diff suppressed because it is too large Load Diff
+22 -3
View File
@@ -6,6 +6,10 @@ import (
"errors" "errors"
"strconv" "strconv"
"strings" "strings"
"time"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
) )
var ( var (
@@ -13,7 +17,7 @@ var (
ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\") ErrAlertWithInvalidDescription = errors.New("alert description must not have \" or \\")
) )
// Alert is a endpoint.Endpoint's alert configuration // Alert is endpoint.Endpoint's alert configuration
type Alert struct { type Alert struct {
// Type of alert (required) // Type of alert (required)
Type Type `yaml:"type"` Type Type `yaml:"type"`
@@ -32,17 +36,24 @@ type Alert struct {
// SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved // SuccessThreshold defines how many successful executions must happen in a row before an ongoing incident is marked as resolved
SuccessThreshold int `yaml:"success-threshold"` 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. // 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 // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work. // or not for provider.ParseWithDefaultAlert to work.
Description *string `yaml:"description"` Description *string `yaml:"description,omitempty"`
// SendOnResolved defines whether to send a second notification when the issue has been resolved // SendOnResolved defines whether to send a second notification when the issue has been resolved
// //
// This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value // This is a pointer, because it is populated by YAML and we need to know whether it was explicitly set to a value
// or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer // or not for provider.ParseWithDefaultAlert to work. Use Alert.IsSendingOnResolved() for a non-pointer
SendOnResolved *bool `yaml:"send-on-resolved"` SendOnResolved *bool `yaml:"send-on-resolved,omitempty"`
// ProviderOverride is an optional field that can be used to override the provider's configuration
// It is freeform so that it can be used for any provider-specific configuration.
ProviderOverride map[string]any `yaml:"provider-override,omitempty"`
// ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve // ResolveKey is an optional field that is used by some providers (i.e. PagerDuty's dedup_key) to resolve
// ongoing/triggered incidents // ongoing/triggered incidents
@@ -111,3 +122,11 @@ func (alert *Alert) Checksum() string {
) )
return hex.EncodeToString(hash.Sum(nil)) return hex.EncodeToString(hash.Sum(nil))
} }
func (alert *Alert) ProviderOverrideAsBytes() []byte {
yamlBytes, err := yaml.Marshal(alert.ProviderOverride)
if err != nil {
logr.Warnf("[alert.ProviderOverrideAsBytes] Failed to marshal alert override of type=%s as bytes: %v", alert.Type, err)
}
return yamlBytes
}
+15
View File
@@ -32,6 +32,15 @@ const (
// TypeGotify is the Type for the gotify alerting provider // TypeGotify is the Type for the gotify alerting provider
TypeGotify Type = "gotify" TypeGotify Type = "gotify"
// TypeHomeAssistant is the Type for the homeassistant alerting provider
TypeHomeAssistant Type = "homeassistant"
// TypeIlert is the Type for the ilert alerting provider
TypeIlert Type = "ilert"
// TypeIncidentIO is the Type for the incident-io alerting provider
TypeIncidentIO Type = "incident-io"
// TypeJetBrainsSpace is the Type for the jetbrains alerting provider // TypeJetBrainsSpace is the Type for the jetbrains alerting provider
TypeJetBrainsSpace Type = "jetbrainsspace" TypeJetBrainsSpace Type = "jetbrainsspace"
@@ -62,9 +71,15 @@ const (
// TypeTeams is the Type for the teams alerting provider // TypeTeams is the Type for the teams alerting provider
TypeTeams Type = "teams" TypeTeams Type = "teams"
// TypeTeamsWorkflows is the Type for the teams-workflows alerting provider
TypeTeamsWorkflows Type = "teams-workflows"
// TypeTelegram is the Type for the telegram alerting provider // TypeTelegram is the Type for the telegram alerting provider
TypeTelegram Type = "telegram" TypeTelegram Type = "telegram"
// TypeTwilio is the Type for the twilio alerting provider // TypeTwilio is the Type for the twilio alerting provider
TypeTwilio Type = "twilio" TypeTwilio Type = "twilio"
// TypeZulip is the Type for the Zulip alerting provider
TypeZulip Type = "zulip"
) )
+22 -2
View File
@@ -1,7 +1,6 @@
package alerting package alerting
import ( import (
"log"
"reflect" "reflect"
"strings" "strings"
@@ -16,6 +15,9 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "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/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -26,8 +28,11 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams" "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/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/logr"
) )
// Config is the configuration for alerting providers // Config is the configuration for alerting providers
@@ -58,6 +63,15 @@ type Config struct {
// Gotify is the configuration for the gotify alerting provider // Gotify is the configuration for the gotify alerting provider
Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"` Gotify *gotify.AlertProvider `yaml:"gotify,omitempty"`
// HomeAssistant is the configuration for the homeassistant alerting provider
HomeAssistant *homeassistant.AlertProvider `yaml:"homeassistant,omitempty"`
// Ilert is the configuration for the ilert alerting provider
Ilert *ilert.AlertProvider `yaml:"ilert,omitempty"`
// IncidentIO is the configuration for the incident-io alerting provider
IncidentIO *incidentio.AlertProvider `yaml:"incident-io,omitempty"`
// JetBrainsSpace is the configuration for the jetbrains space alerting provider // JetBrainsSpace is the configuration for the jetbrains space alerting provider
JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"` JetBrainsSpace *jetbrainsspace.AlertProvider `yaml:"jetbrainsspace,omitempty"`
@@ -89,11 +103,17 @@ type Config struct {
// Teams is the configuration for the teams alerting provider // Teams is the configuration for the teams alerting provider
Teams *teams.AlertProvider `yaml:"teams,omitempty"` Teams *teams.AlertProvider `yaml:"teams,omitempty"`
// TeamsWorkflows is the configuration for the teams alerting provider using the new Workflow App Webhook Connector
TeamsWorkflows *teamsworkflows.AlertProvider `yaml:"teams-workflows,omitempty"`
// Telegram is the configuration for the telegram alerting provider // Telegram is the configuration for the telegram alerting provider
Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"` Telegram *telegram.AlertProvider `yaml:"telegram,omitempty"`
// Twilio is the configuration for the twilio alerting provider // Twilio is the configuration for the twilio alerting provider
Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"` Twilio *twilio.AlertProvider `yaml:"twilio,omitempty"`
// Zulip is the configuration for the zulip alerting provider
Zulip *zulip.AlertProvider `yaml:"zulip,omitempty"`
} }
// GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type // GetAlertingProviderByAlertType returns an provider.AlertProvider by its corresponding alert.Type
@@ -110,7 +130,7 @@ func (config *Config) GetAlertingProviderByAlertType(alertType alert.Type) provi
return fieldValue.Interface().(provider.AlertProvider) return fieldValue.Interface().(provider.AlertProvider)
} }
} }
log.Printf("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType) logr.Infof("[alerting.GetAlertingProviderByAlertType] No alerting provider found for alert type %s", alertType)
return nil return nil
} }
+102 -41
View File
@@ -1,30 +1,73 @@
package awsses package awsses
import ( import (
"errors"
"fmt" "fmt"
"strings" "strings"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/awserr" "github.com/aws/aws-sdk-go/aws/awserr"
"github.com/aws/aws-sdk-go/aws/credentials" "github.com/aws/aws-sdk-go/aws/credentials"
"github.com/aws/aws-sdk-go/aws/session" "github.com/aws/aws-sdk-go/aws/session"
"github.com/aws/aws-sdk-go/service/ses" "github.com/aws/aws-sdk-go/service/ses"
"gopkg.in/yaml.v3"
) )
const ( const (
CharSet = "UTF-8" CharSet = "UTF-8"
) )
// AlertProvider is the configuration necessary for sending an alert using AWS Simple Email Service var (
type AlertProvider struct { ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrMissingFromOrToFields = errors.New("from and to fields are required")
ErrInvalidAWSAuthConfig = errors.New("either both or neither of access-key-id and secret-access-key must be specified")
)
type Config struct {
AccessKeyID string `yaml:"access-key-id"` AccessKeyID string `yaml:"access-key-id"`
SecretAccessKey string `yaml:"secret-access-key"` SecretAccessKey string `yaml:"secret-access-key"`
Region string `yaml:"region"` Region string `yaml:"region"`
From string `yaml:"from"` From string `yaml:"from"`
To string `yaml:"to"` To string `yaml:"to"`
}
func (cfg *Config) Validate() error {
if len(cfg.From) == 0 || len(cfg.To) == 0 {
return ErrMissingFromOrToFields
}
if !((len(cfg.AccessKeyID) == 0 && len(cfg.SecretAccessKey) == 0) || (len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0)) {
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate,
// otherwise if neither are specified, then we'll fall back on IAM authentication.
return ErrInvalidAWSAuthConfig
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AccessKeyID) > 0 {
cfg.AccessKeyID = override.AccessKeyID
}
if len(override.SecretAccessKey) > 0 {
cfg.SecretAccessKey = override.SecretAccessKey
}
if len(override.Region) > 0 {
cfg.Region = override.Region
}
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 AWS Simple Email Service
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -35,36 +78,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
To string `yaml:"to"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
// if both AccessKeyID and SecretAccessKey are specified, we'll use these to authenticate, return provider.DefaultConfig.Validate()
// otherwise if neither are specified, then we'll fall back on IAM authentication.
return len(provider.From) > 0 && len(provider.To) > 0 &&
((len(provider.AccessKeyID) == 0 && len(provider.SecretAccessKey) == 0) || (len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0))
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
sess, err := provider.createSession() cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil { if err != nil {
return err return err
} }
svc := ses.New(sess) awsSession, err := provider.createSession(cfg)
if err != nil {
return err
}
svc := ses.New(awsSession)
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
emails := strings.Split(provider.getToForGroup(ep.Group), ",") emails := strings.Split(cfg.To, ",")
input := &ses.SendEmailInput{ input := &ses.SendEmailInput{
Destination: &ses.Destination{ Destination: &ses.Destination{
@@ -82,26 +126,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
Data: aws.String(subject), Data: aws.String(subject),
}, },
}, },
Source: aws.String(provider.From), Source: aws.String(cfg.From),
} }
_, err = svc.SendEmail(input) if _, err = svc.SendEmail(input); err != nil {
if err != nil {
if aerr, ok := err.(awserr.Error); ok { if aerr, ok := err.(awserr.Error); ok {
switch aerr.Code() { switch aerr.Code() {
case ses.ErrCodeMessageRejected: case ses.ErrCodeMessageRejected:
fmt.Println(ses.ErrCodeMessageRejected, aerr.Error()) logr.Error(ses.ErrCodeMessageRejected + ": " + aerr.Error())
case ses.ErrCodeMailFromDomainNotVerifiedException: case ses.ErrCodeMailFromDomainNotVerifiedException:
fmt.Println(ses.ErrCodeMailFromDomainNotVerifiedException, aerr.Error()) logr.Error(ses.ErrCodeMailFromDomainNotVerifiedException + ": " + aerr.Error())
case ses.ErrCodeConfigurationSetDoesNotExistException: case ses.ErrCodeConfigurationSetDoesNotExistException:
fmt.Println(ses.ErrCodeConfigurationSetDoesNotExistException, aerr.Error()) logr.Error(ses.ErrCodeConfigurationSetDoesNotExistException + ": " + aerr.Error())
default: default:
fmt.Println(aerr.Error()) logr.Error(aerr.Error())
} }
} else { } else {
// Print the error, cast err to awserr.Error to get the Code and // Print the error, cast err to awserr.Error to get the Code and
// Message from an error. // Message from an error.
fmt.Println(err.Error()) logr.Error(err.Error())
} }
return err return err
@@ -109,6 +151,16 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return nil return nil
} }
func (provider *AlertProvider) createSession(cfg *Config) (*session.Session, error) {
awsConfig := &aws.Config{
Region: aws.String(cfg.Region),
}
if len(cfg.AccessKeyID) > 0 && len(cfg.SecretAccessKey) > 0 {
awsConfig.Credentials = credentials.NewStaticCredentials(cfg.AccessKeyID, cfg.SecretAccessKey, "")
}
return session.NewSession(awsConfig)
}
// buildMessageSubjectAndBody builds the message subject and body // buildMessageSubjectAndBody builds the message subject and body
func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) { func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) (string, string) {
var subject, message string var subject, message string
@@ -139,29 +191,38 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
return subject, message + description + formattedConditionResults return subject, message + description + formattedConditionResults
} }
// getToForGroup returns the appropriate email integration to for a given group
func (provider *AlertProvider) getToForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.To
}
}
}
return provider.To
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert return provider.DefaultAlert
} }
func (provider *AlertProvider) createSession() (*session.Session, error) { // GetConfig returns the configuration for the provider with the overrides applied
config := &aws.Config{ func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
Region: aws.String(provider.Region), cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
} }
if len(provider.AccessKeyID) > 0 && len(provider.SecretAccessKey) > 0 { // Handle alert overrides
config.Credentials = credentials.NewStaticCredentials(provider.AccessKeyID, provider.SecretAccessKey, "") if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
} }
return session.NewSession(config) // 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
} }
+101 -39
View File
@@ -7,59 +7,61 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
invalidProviderWithOneKey := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"} invalidProviderWithOneKey := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1"}}
if invalidProviderWithOneKey.IsValid() { if err := invalidProviderWithOneKey.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{From: "from@example.com", To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
validProviderWithKeys := AlertProvider{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"} validProviderWithKeys := AlertProvider{DefaultConfig: Config{From: "from@example.com", To: "to@example.com", AccessKeyID: "1", SecretAccessKey: "1"}}
if !validProviderWithKeys.IsValid() { if err := validProviderWithKeys.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "", Config: Config{To: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
From: "from@example.com", DefaultConfig: Config{
To: "to@example.com", From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -124,64 +126,124 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getToForGroup(t *testing.T) { func TestAlertProvider_getConfigWithOverrides(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "groupto@example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to01@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "wow", AccessKeyID: "noway"},
},
{
Name: "provider-with-override-specify-group-but-alert-override-should-override-group-override",
Provider: AlertProvider{
DefaultConfig: Config{
From: "from@example.com",
To: "to@example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{From: "from@example.com", To: "groupto@example.com", SecretAccessKey: "sekrit"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{
ProviderOverride: map[string]any{
"to": "alertto@example.com",
"access-key-id": 123,
},
},
ExpectedOutput: Config{To: "alertto@example.com", From: "from@example.com", AccessKeyID: "123", SecretAccessKey: "sekrit"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
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)
}
if got.AccessKeyID != scenario.ExpectedOutput.AccessKeyID {
t.Errorf("expected AccessKeyID to be %s, got %s", scenario.ExpectedOutput.AccessKeyID, got.AccessKeyID)
}
if got.SecretAccessKey != scenario.ExpectedOutput.SecretAccessKey {
t.Errorf("expected SecretAccessKey to be %s, got %s", scenario.ExpectedOutput.SecretAccessKey, got.SecretAccessKey)
}
if got.Region != scenario.ExpectedOutput.Region {
t.Errorf("expected Region to be %s, got %s", scenario.ExpectedOutput.Region, got.Region)
}
// 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)
} }
}) })
} }
+133 -51
View File
@@ -2,6 +2,7 @@ package custom
import ( import (
"bytes" "bytes"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,11 +11,14 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request var (
// Technically, all alert providers should be reachable using the custom alert provider ErrURLNotSet = errors.New("url not set")
type AlertProvider struct { )
type Config struct {
URL string `yaml:"url"` URL string `yaml:"url"`
Method string `yaml:"method,omitempty"` Method string `yaml:"method,omitempty"`
Body string `yaml:"body,omitempty"` Body string `yaml:"body,omitempty"`
@@ -23,64 +27,66 @@ type AlertProvider struct {
// ClientConfig is the configuration of the client used to communicate with the provider's target // ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.Method) > 0 {
cfg.Method = override.Method
}
if len(override.Body) > 0 {
cfg.Body = override.Body
}
if len(override.Headers) > 0 {
cfg.Headers = override.Headers
}
if len(override.Placeholders) > 0 {
cfg.Placeholders = override.Placeholders
}
}
// AlertProvider is the configuration necessary for sending an alert using a custom HTTP request
// Technically, all alert providers should be reachable using the custom alert provider
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` 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"`
} }
// IsValid returns whether the provider's configuration is valid // Override is a case under which the default integration is overridden
func (provider *AlertProvider) IsValid() bool { type Override struct {
if provider.ClientConfig == nil { Group string `yaml:"group"`
provider.ClientConfig = client.GetDefaultConfig() Config `yaml:",inline"`
}
return len(provider.URL) > 0 && provider.ClientConfig != nil
} }
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured // Validate the provider's configuration
func (provider *AlertProvider) GetAlertStatePlaceholderValue(resolved bool) string { func (provider *AlertProvider) Validate() error {
status := "TRIGGERED" return provider.DefaultConfig.Validate()
if resolved {
status = "RESOLVED"
}
if _, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
if val, ok := provider.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
return val
}
}
return status
}
func (provider *AlertProvider) buildHTTPRequest(ep *endpoint.Endpoint, alert *alert.Alert, resolved bool) *http.Request {
body, url, method := provider.Body, provider.URL, provider.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range provider.Headers {
request.Header.Set(k, v)
}
return request
} }
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
request := provider.buildHTTPRequest(ep, alert, resolved) cfg, err := provider.GetConfig(ep.Group, alert)
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) if err != nil {
return err
}
request := provider.buildHTTPRequest(cfg, ep, alert, result, resolved)
response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -92,7 +98,83 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err return err
} }
func (provider *AlertProvider) buildHTTPRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) *http.Request {
body, url, method := cfg.Body, cfg.URL, cfg.Method
body = strings.ReplaceAll(body, "[ALERT_DESCRIPTION]", alert.GetDescription())
url = strings.ReplaceAll(url, "[ALERT_DESCRIPTION]", alert.GetDescription())
body = strings.ReplaceAll(body, "[ENDPOINT_NAME]", ep.Name)
url = strings.ReplaceAll(url, "[ENDPOINT_NAME]", ep.Name)
body = strings.ReplaceAll(body, "[ENDPOINT_GROUP]", ep.Group)
url = strings.ReplaceAll(url, "[ENDPOINT_GROUP]", ep.Group)
body = strings.ReplaceAll(body, "[ENDPOINT_URL]", ep.URL)
url = strings.ReplaceAll(url, "[ENDPOINT_URL]", ep.URL)
resultErrors := strings.ReplaceAll(strings.Join(result.Errors, ","), "\"", "\\\"")
body = strings.ReplaceAll(body, "[RESULT_ERRORS]", resultErrors)
url = strings.ReplaceAll(url, "[RESULT_ERRORS]", resultErrors)
if resolved {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, true))
} else {
body = strings.ReplaceAll(body, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
url = strings.ReplaceAll(url, "[ALERT_TRIGGERED_OR_RESOLVED]", provider.GetAlertStatePlaceholderValue(cfg, false))
}
if len(method) == 0 {
method = http.MethodGet
}
bodyBuffer := bytes.NewBuffer([]byte(body))
request, _ := http.NewRequest(method, url, bodyBuffer)
for k, v := range cfg.Headers {
request.Header.Set(k, v)
}
return request
}
// GetAlertStatePlaceholderValue returns the Placeholder value for ALERT_TRIGGERED_OR_RESOLVED if configured
func (provider *AlertProvider) GetAlertStatePlaceholderValue(cfg *Config, resolved bool) string {
status := "TRIGGERED"
if resolved {
status = "RESOLVED"
}
if _, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"]; ok {
if val, ok := cfg.Placeholders["ALERT_TRIGGERED_OR_RESOLVED"][status]; ok {
return val
}
}
return status
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+215 -39
View File
@@ -12,24 +12,18 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{URL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{URL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("valid-provider", func(t *testing.T) { t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{URL: "https://example.com"} validProvider := AlertProvider{DefaultConfig: Config{URL: "https://example.com"}}
if validProvider.ClientConfig != nil { if err := validProvider.Validate(); err != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
}) })
} }
@@ -47,7 +41,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -57,7 +51,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -67,7 +61,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -77,7 +71,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -111,9 +105,11 @@ func TestAlertProvider_Send(t *testing.T) {
} }
func TestAlertProvider_buildHTTPRequest(t *testing.T) { func TestAlertProvider_buildHTTPRequest(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]", DefaultConfig: Config{
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED]",
},
} }
alertDescription := "alert-description" alertDescription := "alert-description"
scenarios := []struct { scenarios := []struct {
@@ -123,13 +119,13 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
ExpectedBody string ExpectedBody string
}{ }{
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: true, Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED", ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED",
}, },
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: false, Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED", ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED",
@@ -137,9 +133,67 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: []string{}},
scenario.Resolved,
)
if request.URL.String() != scenario.ExpectedURL {
t.Error("expected URL to be", scenario.ExpectedURL, "got", request.URL.String())
}
body, _ := io.ReadAll(request.Body)
if string(body) != scenario.ExpectedBody {
t.Error("expected body to be", scenario.ExpectedBody, "got", string(body))
}
})
}
}
func TestAlertProviderWithResultErrors_buildHTTPRequest(t *testing.T) {
alertProvider := &AlertProvider{
DefaultConfig: Config{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]&url=[ENDPOINT_URL]&error=[RESULT_ERRORS]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ENDPOINT_URL],[ALERT_TRIGGERED_OR_RESOLVED],[RESULT_ERRORS]",
},
}
alertDescription := "alert-description"
scenarios := []struct {
AlertProvider *AlertProvider
Resolved bool
ExpectedURL string
ExpectedBody string
Errors []string
}{
{
AlertProvider: alertProvider,
Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=RESOLVED&description=alert-description&url=https://example.com&error=",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,RESOLVED,",
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=error1,error2",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,error1,error2",
Errors: []string{"error1", "error2"},
},
{
AlertProvider: alertProvider,
Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=TRIGGERED&description=alert-description&url=https://example.com&error=test \\\"error with quotes\\\"",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,https://example.com,TRIGGERED,test \\\"error with quotes\\\"",
Errors: []string{"test \"error with quotes\""},
},
}
for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-default-placeholders-and-result-errors", scenario.Resolved), func(t *testing.T) {
request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group", URL: "https://example.com"},
&alert.Alert{Description: &alertDescription},
&endpoint.Result{Errors: scenario.Errors},
scenario.Resolved, scenario.Resolved,
) )
if request.URL.String() != scenario.ExpectedURL { if request.URL.String() != scenario.ExpectedURL {
@@ -154,14 +208,16 @@ func TestAlertProvider_buildHTTPRequest(t *testing.T) {
} }
func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) { func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", DefaultConfig: Config{
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", URL: "https://example.com/[ENDPOINT_GROUP]/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Headers: nil, Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
Placeholders: map[string]map[string]string{ Headers: nil,
"ALERT_TRIGGERED_OR_RESOLVED": { Placeholders: map[string]map[string]string{
"RESOLVED": "fixed", "ALERT_TRIGGERED_OR_RESOLVED": {
"TRIGGERED": "boom", "RESOLVED": "fixed",
"TRIGGERED": "boom",
},
}, },
}, },
} }
@@ -173,13 +229,13 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
ExpectedBody string ExpectedBody string
}{ }{
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: true, Resolved: true,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=fixed&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed", ExpectedBody: "endpoint-name,endpoint-group,alert-description,fixed",
}, },
{ {
AlertProvider: customAlertProvider, AlertProvider: alertProvider,
Resolved: false, Resolved: false,
ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description", ExpectedURL: "https://example.com/endpoint-group/endpoint-name?event=boom&description=alert-description",
ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom", ExpectedBody: "endpoint-name,endpoint-group,alert-description,boom",
@@ -187,9 +243,11 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) { t.Run(fmt.Sprintf("resolved-%v-with-custom-placeholders", scenario.Resolved), func(t *testing.T) {
request := customAlertProvider.buildHTTPRequest( request := alertProvider.buildHTTPRequest(
&alertProvider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&alert.Alert{Description: &alertDescription}, &alert.Alert{Description: &alertDescription},
&endpoint.Result{},
scenario.Resolved, scenario.Resolved,
) )
if request.URL.String() != scenario.ExpectedURL { if request.URL.String() != scenario.ExpectedURL {
@@ -204,15 +262,17 @@ func TestAlertProvider_buildHTTPRequestWithCustomPlaceholder(t *testing.T) {
} }
func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) { func TestAlertProvider_GetAlertStatePlaceholderValueDefaults(t *testing.T) {
customAlertProvider := &AlertProvider{ alertProvider := &AlertProvider{
URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]", DefaultConfig: Config{
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]", URL: "https://example.com/[ENDPOINT_NAME]?event=[ALERT_TRIGGERED_OR_RESOLVED]&description=[ALERT_DESCRIPTION]",
Body: "[ENDPOINT_NAME],[ENDPOINT_GROUP],[ALERT_DESCRIPTION],[ALERT_TRIGGERED_OR_RESOLVED]",
},
} }
if customAlertProvider.GetAlertStatePlaceholderValue(true) != "RESOLVED" { if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true) != "RESOLVED" {
t.Error("expected RESOLVED, got", customAlertProvider.GetAlertStatePlaceholderValue(true)) t.Error("expected RESOLVED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, true))
} }
if customAlertProvider.GetAlertStatePlaceholderValue(false) != "TRIGGERED" { if alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false) != "TRIGGERED" {
t.Error("expected TRIGGERED, got", customAlertProvider.GetAlertStatePlaceholderValue(false)) t.Error("expected TRIGGERED, got", alertProvider.GetAlertStatePlaceholderValue(&alertProvider.DefaultConfig, false))
} }
} }
@@ -224,3 +284,119 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be 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{URL: "http://example.com", Body: "default-body"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com", Body: "default-body"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com", Headers: map[string]string{"Cache": "true"}},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://example.com", Headers: map[string]string{"Cache": "true"}},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com", Body: "default-body"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com", Body: "group-body"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "http://group-example.com", Body: "group-body"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "body": "alert-body"}},
ExpectedOutput: Config{URL: "http://alert-example.com", Body: "alert-body"},
},
{
Name: "provider-with-partial-overrides",
Provider: AlertProvider{
DefaultConfig: Config{URL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{Method: "POST"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"body": "alert-body"}},
ExpectedOutput: Config{URL: "http://example.com", Body: "alert-body", Method: "POST"},
},
}
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.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.Body != scenario.ExpectedOutput.Body {
t.Errorf("expected body to be %s, got %s", scenario.ExpectedOutput.Body, got.Body)
}
if got.Headers != nil {
for key, value := range scenario.ExpectedOutput.Headers {
if got.Headers[key] != value {
t.Errorf("expected header %s to be %s, got %s", key, value, got.Headers[key])
}
}
}
// 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)
}
})
}
}
+75 -28
View File
@@ -3,6 +3,7 @@ package discord
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,46 +11,73 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
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.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord // AlertProvider is the configuration necessary for sending an alert using Discord
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
} }
// Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -85,7 +113,7 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
var colorCode int var colorCode int
if resolved { if resolved {
@@ -110,8 +138,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
title := ":helmet_with_white_cross: Gatus" title := ":helmet_with_white_cross: Gatus"
if provider.Title != "" { if cfg.Title != "" {
title = provider.Title title = cfg.Title
} }
body := Body{ body := Body{
Content: "", Content: "",
@@ -134,19 +162,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON return bodyAsJSON
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+85 -44
View File
@@ -11,50 +11,52 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{
WebhookURL: "http://example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -74,7 +76,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -84,7 +86,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -94,7 +96,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -104,7 +106,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -114,7 +116,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-with-modified-title", Name: "triggered-with-modified-title",
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com", Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -122,6 +124,16 @@ func TestAlertProvider_Send(t *testing.T) {
}), }),
ExpectedError: false, ExpectedError: false,
}, },
{
Name: "triggered-with-webhook-override",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"webhook-url": "http://example01.com"}},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
@@ -175,7 +187,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}, },
{ {
Name: "triggered-with-modified-title", Name: "triggered-with-modified-title",
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}", ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332,\"fields\":[{\"name\":\"Condition results\",\"value\":\":x: - `[CONNECTED] == true`\\n:x: - `[STATUS] == 200`\\n:x: - `[BODY] != \\\"\\\"`\\n\",\"inline\":false}]}]}",
@@ -183,7 +195,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "triggered-with-no-conditions", Name: "triggered-with-no-conditions",
NoConditions: true, NoConditions: true,
Provider: AlertProvider{Title: title}, Provider: AlertProvider{DefaultConfig: Config{Title: title}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}", ExpectedBody: "{\"content\":\"\",\"embeds\":[{\"title\":\"provider-title\",\"description\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\",\"color\":15158332}]}",
@@ -200,6 +212,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -227,64 +240,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// 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)
} }
}) })
} }
+109 -30
View File
@@ -2,6 +2,7 @@ package email
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"math" "math"
"strings" "strings"
@@ -10,10 +11,17 @@ import (
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
gomail "gopkg.in/mail.v2" gomail "gopkg.in/mail.v2"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using SMTP var (
type AlertProvider struct { ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrMissingFromOrToFields = errors.New("from and to fields are required")
ErrInvalidPort = errors.New("port must be between 1 and 65535 inclusively")
ErrMissingHost = errors.New("host is required")
)
type Config struct {
From string `yaml:"from"` From string `yaml:"from"`
Username string `yaml:"username"` Username string `yaml:"username"`
Password string `yaml:"password"` Password string `yaml:"password"`
@@ -23,6 +31,48 @@ type AlertProvider struct {
// ClientConfig is the configuration of the client used to communicate with the provider's target // ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"` ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.From) == 0 || len(cfg.To) == 0 {
return ErrMissingFromOrToFields
}
if cfg.Port < 1 || cfg.Port > math.MaxUint16 {
return ErrInvalidPort
}
if len(cfg.Host) == 0 {
return ErrMissingHost
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.Username) > 0 {
cfg.Username = override.Username
}
if len(override.Password) > 0 {
cfg.Password = override.Password
}
if len(override.Host) > 0 {
cfg.Host = override.Host
}
if override.Port > 0 {
cfg.Port = override.Port
}
if len(override.To) > 0 {
cfg.To = override.To
}
}
// AlertProvider is the configuration necessary for sending an alert using SMTP
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -33,54 +83,57 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
To string `yaml:"to"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.To) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return provider.DefaultConfig.Validate()
return len(provider.From) > 0 && len(provider.Host) > 0 && len(provider.To) > 0 && provider.Port > 0 && provider.Port < math.MaxUint16
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
var username string var username string
if len(provider.Username) > 0 { if len(cfg.Username) > 0 {
username = provider.Username username = cfg.Username
} else { } else {
username = provider.From username = cfg.From
} }
subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved) subject, body := provider.buildMessageSubjectAndBody(ep, alert, result, resolved)
m := gomail.NewMessage() m := gomail.NewMessage()
m.SetHeader("From", provider.From) m.SetHeader("From", cfg.From)
m.SetHeader("To", strings.Split(provider.getToForGroup(ep.Group), ",")...) m.SetHeader("To", strings.Split(cfg.To, ",")...)
m.SetHeader("Subject", subject) m.SetHeader("Subject", subject)
m.SetBody("text/plain", body) m.SetBody("text/plain", body)
var d *gomail.Dialer var d *gomail.Dialer
if len(provider.Password) == 0 { if len(cfg.Password) == 0 {
// Get the domain in the From address // Get the domain in the From address
localName := "localhost" localName := "localhost"
fromParts := strings.Split(provider.From, `@`) fromParts := strings.Split(cfg.From, `@`)
if len(fromParts) == 2 { if len(fromParts) == 2 {
localName = fromParts[1] localName = fromParts[1]
} }
// Create a dialer with no authentication // Create a dialer with no authentication
d = &gomail.Dialer{Host: provider.Host, Port: provider.Port, LocalName: localName} d = &gomail.Dialer{Host: cfg.Host, Port: cfg.Port, LocalName: localName}
} else { } else {
// Create an authenticated dialer // Create an authenticated dialer
d = gomail.NewDialer(provider.Host, provider.Port, username, provider.Password) d = gomail.NewDialer(cfg.Host, cfg.Port, username, cfg.Password)
} }
if provider.ClientConfig != nil && provider.ClientConfig.Insecure { if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
d.TLSConfig = &tls.Config{InsecureSkipVerify: true} d.TLSConfig = &tls.Config{InsecureSkipVerify: true}
} }
return d.DialAndSend(m) return d.DialAndSend(m)
@@ -113,22 +166,48 @@ func (provider *AlertProvider) buildMessageSubjectAndBody(ep *endpoint.Endpoint,
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 { if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
description = "\n\nAlert description: " + alertDescription description = "\n\nAlert description: " + alertDescription
} }
return subject, message + description + formattedConditionResults var extraLabels string
} if len(ep.ExtraLabels) > 0 {
extraLabels = "\n\nExtra labels:\n"
// getToForGroup returns the appropriate email integration to for a given group for key, value := range ep.ExtraLabels {
func (provider *AlertProvider) getToForGroup(group string) string { extraLabels += fmt.Sprintf(" %s: %s\n", key, value)
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.To
}
} }
} }
return provider.To return subject, message + description + extraLabels + formattedConditionResults
} }
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+116 -44
View File
@@ -7,61 +7,63 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Password: "password", Host: "smtp.gmail.com", Port: 587, To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithNoCredentials(t *testing.T) { func TestAlertProvider_ValidateWithNoCredentials(t *testing.T) {
validProvider := AlertProvider{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"} validProvider := AlertProvider{DefaultConfig: Config{From: "from@example.com", Host: "smtp-relay.gmail.com", Port: 587, To: "to@example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
To: "", Config: Config{To: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
From: "from@example.com", DefaultConfig: Config{
Password: "password", From: "from@example.com",
Host: "smtp.gmail.com", Password: "password",
Port: 587, Host: "smtp.gmail.com",
To: "to@example.com", Port: 587,
To: "to@example.com",
},
Overrides: []Override{ Overrides: []Override{
{ {
To: "to@example.com", Config: Config{To: "to@example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -74,6 +76,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
Endpoint *endpoint.Endpoint
ExpectedSubject string ExpectedSubject string
ExpectedBody string ExpectedBody string
}{ }{
@@ -82,6 +85,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert triggered", 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", 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",
}, },
@@ -90,14 +94,42 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
Endpoint: &endpoint.Endpoint{Name: "endpoint-name"},
ExpectedSubject: "[endpoint-name] Alert resolved", 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", 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 { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
subject, body := scenario.Provider.buildMessageSubjectAndBody( subject, body := scenario.Provider.buildMessageSubjectAndBody(
&endpoint.Endpoint{Name: "endpoint-name"}, scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{ ConditionResults: []*endpoint.ConditionResult{
@@ -126,64 +158,104 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getToForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "to01@example.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "to@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
To: "to@example.com", DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
To: "to01@example.com", Config: Config{To: "group-to@example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "to01@example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{From: "from@example.com", To: "group-to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{From: "from@example.com", To: "to@example.com", Host: "smtp.gmail.com", Port: 587, Password: "password"},
Overrides: []Override{
{
Group: "group",
Config: Config{To: "group-to@example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"to": "alert-to@example.com", "host": "smtp.example.com", "port": 588, "password": "hunter2"}},
ExpectedOutput: Config{From: "from@example.com", To: "alert-to@example.com", Host: "smtp.example.com", Port: 588, Password: "hunter2"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getToForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
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 be %s, got %s", scenario.ExpectedOutput.To, got.To)
}
if got.Host != scenario.ExpectedOutput.Host {
t.Errorf("expected host to be %s, got %s", scenario.ExpectedOutput.Host, got.Host)
}
if got.Port != scenario.ExpectedOutput.Port {
t.Errorf("expected port to be %d, got %d", scenario.ExpectedOutput.Port, got.Port)
}
if got.Password != scenario.ExpectedOutput.Password {
t.Errorf("expected password to be %s, got %s", scenario.ExpectedOutput.Password, got.Password)
}
// 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)
} }
}) })
} }
+101 -51
View File
@@ -2,6 +2,7 @@ package gitea
import ( import (
"crypto/tls" "crypto/tls"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"net/url" "net/url"
@@ -11,55 +12,56 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord var (
type AlertProvider struct { ErrRepositoryURLNotSet = errors.New("repository-url not set")
RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in ErrInvalidRepositoryURL = errors.New("invalid repository-url")
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata ErrTokenNotSet = errors.New("token not set")
)
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type type Config struct {
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` RepositoryURL string `yaml:"repository-url"` // The URL of the Gitea repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// ClientConfig is the configuration of the client used to communicate with the provider's target Assignees []string `yaml:"assignees,omitempty"` // Assignees is a list of users to assign the issue to
ClientConfig *client.Config `yaml:"client,omitempty"`
// Assignees is a list of users to assign the issue to
Assignees []string `yaml:"assignees,omitempty"`
username string username string
repositoryOwner string repositoryOwner string
repositoryName string repositoryName string
giteaClient *gitea.Client giteaClient *gitea.Client
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid func (cfg *Config) Validate() error {
func (provider *AlertProvider) IsValid() bool { if len(cfg.RepositoryURL) == 0 {
if provider.ClientConfig == nil { return ErrRepositoryURLNotSet
provider.ClientConfig = client.GetDefaultConfig()
} }
if len(cfg.Token) == 0 {
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 { return ErrTokenNotSet
return false
} }
// Validate format of the repository URL // Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL) repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil { if err != nil {
return false return err
} }
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/") pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 { if len(pathParts) != 3 {
return false return ErrInvalidRepositoryURL
} }
provider.repositoryOwner = pathParts[1] if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.giteaClient != nil {
provider.repositoryName = pathParts[2] // Already validated, let's skip the rest of the validation to avoid unnecessary API calls
return nil
}
cfg.repositoryOwner = pathParts[1]
cfg.repositoryName = pathParts[2]
opts := []gitea.ClientOption{ opts := []gitea.ClientOption{
gitea.SetToken(provider.Token), gitea.SetToken(cfg.Token),
} }
if cfg.ClientConfig != nil && cfg.ClientConfig.Insecure {
if provider.ClientConfig != nil && provider.ClientConfig.Insecure {
// add new http client for skip verify // add new http client for skip verify
httpClient := &http.Client{ httpClient := &http.Client{
Transport: &http.Transport{ Transport: &http.Transport{
@@ -68,34 +70,62 @@ func (provider *AlertProvider) IsValid() bool {
} }
opts = append(opts, gitea.SetHTTPClient(httpClient)) opts = append(opts, gitea.SetHTTPClient(httpClient))
} }
cfg.giteaClient, err = gitea.NewClient(baseURL, opts...)
provider.giteaClient, err = gitea.NewClient(baseURL, opts...)
if err != nil { if err != nil {
return false return err
} }
user, _, err := cfg.giteaClient.GetMyUserInfo()
user, _, err := provider.giteaClient.GetMyUserInfo()
if err != nil { if err != nil {
return false return err
} }
cfg.username = user.UserName
return nil
}
provider.username = user.UserName func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.RepositoryURL) > 0 {
cfg.RepositoryURL = override.RepositoryURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.Assignees) > 0 {
cfg.Assignees = override.Assignees
}
}
return true // AlertProvider is the configuration necessary for sending an alert using Discord
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
} }
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
title := "alert(gatus): " + ep.DisplayName() title := "alert(gatus): " + ep.DisplayName()
if !resolved { if !resolved {
_, _, err := provider.giteaClient.CreateIssue( _, _, err = cfg.giteaClient.CreateIssue(
provider.repositoryOwner, cfg.repositoryOwner,
provider.repositoryName, cfg.repositoryName,
gitea.CreateIssueOption{ gitea.CreateIssueOption{
Title: title, Title: title,
Body: provider.buildIssueBody(ep, alert, result), Body: provider.buildIssueBody(ep, alert, result),
Assignees: provider.Assignees, Assignees: cfg.Assignees,
}, },
) )
if err != nil { if err != nil {
@@ -103,29 +133,27 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
} }
return nil return nil
} }
issues, _, err := cfg.giteaClient.ListRepoIssues(
issues, _, err := provider.giteaClient.ListRepoIssues( cfg.repositoryOwner,
provider.repositoryOwner, cfg.repositoryName,
provider.repositoryName,
gitea.ListIssueOption{ gitea.ListIssueOption{
State: gitea.StateOpen, State: gitea.StateOpen,
CreatedBy: provider.username, CreatedBy: cfg.username,
ListOptions: gitea.ListOptions{ ListOptions: gitea.ListOptions{
Page: 100, PageSize: 100,
}, },
}, },
) )
if err != nil { if err != nil {
return fmt.Errorf("failed to list issues: %w", err) return fmt.Errorf("failed to list issues: %w", err)
} }
for _, issue := range issues { for _, issue := range issues {
if issue.Title == title { if issue.Title == title {
stateClosed := gitea.StateClosed stateClosed := gitea.StateClosed
_, _, err = provider.giteaClient.EditIssue( _, _, err = cfg.giteaClient.EditIssue(
provider.repositoryOwner, cfg.repositoryOwner,
provider.repositoryName, cfg.repositoryName,
issue.ID, issue.Index,
gitea.EditIssueOption{ gitea.EditIssueOption{
State: &stateClosed, State: &stateClosed,
}, },
@@ -165,3 +193,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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 (we're returning the cfg here even if there's an error mostly for testing purposes)
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
}
+85 -25
View File
@@ -12,42 +12,46 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Expected bool ExpectedError bool
}{ }{
{ {
Name: "invalid", Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-token", Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-repository-name", Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "enterprise-client", Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.example.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: false,
}, },
{ {
Name: "invalid-url", Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "gitea.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected { err := scenario.Provider.Validate()
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
} }
}) })
} }
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: true, ExpectedError: true,
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: true, ExpectedError: true,
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.giteaClient, _ = gitea.NewClient("https://gitea.com") cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
cfg.giteaClient, _ = gitea.NewClient("https://gitea.com")
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -167,3 +175,55 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://gitea.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://gitea.com/TwiN/alert-test", "token": "54321", "assignees": []string{"TwiN"}}},
ExpectedOutput: Config{RepositoryURL: "https://gitea.com/TwiN/alert-test", Token: "54321", Assignees: []string{"TwiN"}},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if len(got.Assignees) != len(scenario.ExpectedOutput.Assignees) {
t.Errorf("expected %d assignees, got %d", len(scenario.ExpectedOutput.Assignees), len(got.Assignees))
}
for i, assignee := range got.Assignees {
if assignee != scenario.ExpectedOutput.Assignees[i] {
t.Errorf("expected assignee %s, got %s", scenario.ExpectedOutput.Assignees[i], assignee)
}
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "user does not exist") {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+84 -26
View File
@@ -2,6 +2,7 @@ package github
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/url" "net/url"
"strings" "strings"
@@ -11,69 +12,104 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
"golang.org/x/oauth2" "golang.org/x/oauth2"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Discord var (
type AlertProvider struct { ErrRepositoryURLNotSet = errors.New("repository-url not set")
ErrInvalidRepositoryURL = errors.New("invalid repository-url")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in RepositoryURL string `yaml:"repository-url"` // The URL of the GitHub repository to create issues in
Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata Token string `yaml:"token"` // Token requires at least RW on issues and RO on metadata
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
username string username string
repositoryOwner string repositoryOwner string
repositoryName string repositoryName string
githubClient *github.Client githubClient *github.Client
} }
// IsValid returns whether the provider's configuration is valid func (cfg *Config) Validate() error {
func (provider *AlertProvider) IsValid() bool { if len(cfg.RepositoryURL) == 0 {
if len(provider.Token) == 0 || len(provider.RepositoryURL) == 0 { return ErrRepositoryURLNotSet
return false }
if len(cfg.Token) == 0 {
return ErrTokenNotSet
} }
// Validate format of the repository URL // Validate format of the repository URL
repositoryURL, err := url.Parse(provider.RepositoryURL) repositoryURL, err := url.Parse(cfg.RepositoryURL)
if err != nil { if err != nil {
return false return err
} }
baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host baseURL := repositoryURL.Scheme + "://" + repositoryURL.Host
pathParts := strings.Split(repositoryURL.Path, "/") pathParts := strings.Split(repositoryURL.Path, "/")
if len(pathParts) != 3 { if len(pathParts) != 3 {
return false return ErrInvalidRepositoryURL
} }
provider.repositoryOwner = pathParts[1] if cfg.repositoryOwner == pathParts[1] && cfg.repositoryName == pathParts[2] && cfg.githubClient != nil {
provider.repositoryName = pathParts[2] // Already validated, let's skip the rest of the validation to avoid unnecessary API calls
return nil
}
cfg.repositoryOwner = pathParts[1]
cfg.repositoryName = pathParts[2]
// Create oauth2 HTTP client with GitHub token // Create oauth2 HTTP client with GitHub token
httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{ httpClientWithStaticTokenSource := oauth2.NewClient(context.Background(), oauth2.StaticTokenSource(&oauth2.Token{
AccessToken: provider.Token, AccessToken: cfg.Token,
})) }))
// Create GitHub client // Create GitHub client
if baseURL == "https://github.com" { if baseURL == "https://github.com" {
provider.githubClient = github.NewClient(httpClientWithStaticTokenSource) cfg.githubClient = github.NewClient(httpClientWithStaticTokenSource)
} else { } else {
provider.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource) cfg.githubClient, err = github.NewEnterpriseClient(baseURL, baseURL, httpClientWithStaticTokenSource)
if err != nil { if err != nil {
return false return fmt.Errorf("failed to create enterprise GitHub client: %w", err)
} }
} }
// Retrieve the username once to validate that the token is valid // Retrieve the username once to validate that the token is valid
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel() defer cancel()
user, _, err := provider.githubClient.Users.Get(ctx, "") user, _, err := cfg.githubClient.Users.Get(ctx, "")
if err != nil { if err != nil {
return false return fmt.Errorf("failed to retrieve GitHub user: %w", err)
} }
provider.username = *user.Login cfg.username = *user.Login
return true return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.RepositoryURL) > 0 {
cfg.RepositoryURL = override.RepositoryURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using Discord
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"`
}
// Validate the provider's configuration
func (provider *AlertProvider) Validate() error {
return provider.DefaultConfig.Validate()
} }
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
title := "alert(gatus): " + ep.DisplayName() title := "alert(gatus): " + ep.DisplayName()
if !resolved { if !resolved {
_, _, err := provider.githubClient.Issues.Create(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueRequest{ _, _, err := cfg.githubClient.Issues.Create(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueRequest{
Title: github.String(title), Title: github.String(title),
Body: github.String(provider.buildIssueBody(ep, alert, result)), Body: github.String(provider.buildIssueBody(ep, alert, result)),
}) })
@@ -81,9 +117,9 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return fmt.Errorf("failed to create issue: %w", err) return fmt.Errorf("failed to create issue: %w", err)
} }
} else { } else {
issues, _, err := provider.githubClient.Issues.ListByRepo(context.Background(), provider.repositoryOwner, provider.repositoryName, &github.IssueListByRepoOptions{ issues, _, err := cfg.githubClient.Issues.ListByRepo(context.Background(), cfg.repositoryOwner, cfg.repositoryName, &github.IssueListByRepoOptions{
State: "open", State: "open",
Creator: provider.username, Creator: cfg.username,
ListOptions: github.ListOptions{PerPage: 100}, ListOptions: github.ListOptions{PerPage: 100},
}) })
if err != nil { if err != nil {
@@ -91,7 +127,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
} }
for _, issue := range issues { for _, issue := range issues {
if *issue.Title == title { if *issue.Title == title {
_, _, err = provider.githubClient.Issues.Edit(context.Background(), provider.repositoryOwner, provider.repositoryName, *issue.Number, &github.IssueRequest{ _, _, err = cfg.githubClient.Issues.Edit(context.Background(), cfg.repositoryOwner, cfg.repositoryName, *issue.Number, &github.IssueRequest{
State: github.String("closed"), State: github.String("closed"),
}) })
if err != nil { if err != nil {
@@ -130,3 +166,25 @@ func (provider *AlertProvider) buildIssueBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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 (we're returning the cfg here even if there's an error mostly for testing purposes)
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
}
+77 -25
View File
@@ -12,42 +12,46 @@ import (
"github.com/google/go-github/v48/github" "github.com/google/go-github/v48/github"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Expected bool ExpectedError bool
}{ }{
{ {
Name: "invalid", Name: "invalid",
Provider: AlertProvider{RepositoryURL: "", Token: ""}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "", Token: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-token", Name: "invalid-token",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-repository-name", Name: "missing-repository-name",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "enterprise-client", Name: "enterprise-client",
Provider: AlertProvider{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.example.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-url", Name: "invalid-url",
Provider: AlertProvider{RepositoryURL: "github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "github.com/TwiN/test", Token: "12345"}},
Expected: false, ExpectedError: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected { err := scenario.Provider.Validate()
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
} }
}) })
} }
@@ -67,14 +71,14 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: true, ExpectedError: true,
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}, Provider: AlertProvider{DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: true, ExpectedError: true,
@@ -82,9 +86,13 @@ func TestAlertProvider_Send(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
scenario.Provider.githubClient = github.NewClient(nil) cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
}
cfg.githubClient = github.NewClient(nil)
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
err := scenario.Provider.Send( err = scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"}, &endpoint.Endpoint{Name: "endpoint-name", Group: "endpoint-group"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -167,3 +175,47 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{RepositoryURL: "https://github.com/TwiN/test", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "token": "54321"}},
ExpectedOutput: Config{RepositoryURL: "https://github.com/TwiN/alert-test", Token: "54321"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.RepositoryURL != scenario.ExpectedOutput.RepositoryURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.RepositoryURL, got.RepositoryURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil && !strings.Contains(err.Error(), "failed to retrieve GitHub user") {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+102 -48
View File
@@ -13,55 +13,97 @@ import (
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/google/uuid" "github.com/google/uuid"
"gopkg.in/yaml.v3"
) )
const (
DefaultSeverity = "critical"
DefaultMonitoringTool = "gatus"
)
var (
ErrInvalidWebhookURL = fmt.Errorf("invalid webhook-url")
ErrAuthorizationKeyNotSet = fmt.Errorf("authorization-key not set")
)
type Config struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
Severity string `yaml:"severity,omitempty"` // Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
MonitoringTool string `yaml:"monitoring-tool,omitempty"` // MonitoringTool overrides the name sent to gitlab. Defaults to gatus
EnvironmentName string `yaml:"environment-name,omitempty"` // EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
Service string `yaml:"service,omitempty"` // Service affected. Defaults to the endpoint's display name
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrInvalidWebhookURL
} else if _, err := url.Parse(cfg.WebhookURL); err != nil {
return ErrInvalidWebhookURL
}
if len(cfg.AuthorizationKey) == 0 {
return ErrAuthorizationKeyNotSet
}
if len(cfg.Severity) == 0 {
cfg.Severity = DefaultSeverity
}
if len(cfg.MonitoringTool) == 0 {
cfg.MonitoringTool = DefaultMonitoringTool
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.AuthorizationKey) > 0 {
cfg.AuthorizationKey = override.AuthorizationKey
}
if len(override.Severity) > 0 {
cfg.Severity = override.Severity
}
if len(override.MonitoringTool) > 0 {
cfg.MonitoringTool = override.MonitoringTool
}
if len(override.EnvironmentName) > 0 {
cfg.EnvironmentName = override.EnvironmentName
}
if len(override.Service) > 0 {
cfg.Service = override.Service
}
}
// AlertProvider is the configuration necessary for sending an alert using GitLab // AlertProvider is the configuration necessary for sending an alert using GitLab
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // The webhook url provided by GitLab DefaultConfig Config `yaml:",inline"`
AuthorizationKey string `yaml:"authorization-key"` // The authorization key provided by GitLab
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Severity can be one of: critical, high, medium, low, info, unknown. Defaults to critical
Severity string `yaml:"severity,omitempty"`
// MonitoringTool overrides the name sent to gitlab. Defaults to gatus
MonitoringTool string `yaml:"monitoring-tool,omitempty"`
// EnvironmentName is the name of the associated GitLab environment. Required to display alerts on a dashboard.
EnvironmentName string `yaml:"environment-name,omitempty"`
// Service affected. Defaults to endpoint display name
Service string `yaml:"service,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if len(provider.AuthorizationKey) == 0 || len(provider.WebhookURL) == 0 { return provider.DefaultConfig.Validate()
return false
}
// Validate format of the repository URL
_, err := url.Parse(provider.WebhookURL)
if err != nil {
return false
}
return true
} }
// Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false, // Send creates an issue in the designed RepositoryURL if the resolved parameter passed is false,
// or closes the relevant issue(s) if the resolved parameter passed is true. // or closes the relevant issue(s) if the resolved parameter passed is true.
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
if len(alert.ResolveKey) == 0 { if len(alert.ResolveKey) == 0 {
alert.ResolveKey = uuid.NewString() alert.ResolveKey = uuid.NewString()
} }
buffer := bytes.NewBuffer(provider.buildAlertBody(ep, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildAlertBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.WebhookURL, buffer) request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", provider.AuthorizationKey)) request.Header.Set("Authorization", fmt.Sprintf("Bearer %s", cfg.AuthorizationKey))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -87,30 +129,20 @@ type AlertBody struct {
GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard. GitlabEnvironmentName string `json:"gitlab_environment_name,omitempty"` // The name of the associated GitLab environment. Required to display alerts on a dashboard.
} }
func (provider *AlertProvider) monitoringTool() string {
if len(provider.MonitoringTool) > 0 {
return provider.MonitoringTool
}
return "gatus"
}
func (provider *AlertProvider) service(ep *endpoint.Endpoint) string {
if len(provider.Service) > 0 {
return provider.Service
}
return ep.DisplayName()
}
// buildAlertBody builds the body of the alert // buildAlertBody builds the body of the alert
func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildAlertBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
service := cfg.Service
if len(service) == 0 {
service = ep.DisplayName()
}
body := AlertBody{ body := AlertBody{
Title: fmt.Sprintf("alert(%s): %s", provider.monitoringTool(), provider.service(ep)), Title: fmt.Sprintf("alert(%s): %s", cfg.MonitoringTool, service),
StartTime: result.Timestamp.Format(time.RFC3339), StartTime: result.Timestamp.Format(time.RFC3339),
Service: provider.service(ep), Service: service,
MonitoringTool: provider.monitoringTool(), MonitoringTool: cfg.MonitoringTool,
Hosts: ep.URL, Hosts: ep.URL,
GitlabEnvironmentName: provider.EnvironmentName, GitlabEnvironmentName: cfg.EnvironmentName,
Severity: provider.Severity, Severity: cfg.Severity,
Fingerprint: alert.ResolveKey, Fingerprint: alert.ResolveKey,
} }
if resolved { if resolved {
@@ -148,3 +180,25 @@ func (provider *AlertProvider) buildAlertBody(ep *endpoint.Endpoint, alert *aler
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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 (we're returning the cfg here even if there's an error mostly for testing purposes)
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
}
+89 -24
View File
@@ -11,37 +11,41 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Expected bool ExpectedError bool
}{ }{
{ {
Name: "invalid", Name: "invalid",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: ""}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-webhook-url", Name: "missing-webhook-url",
Provider: AlertProvider{WebhookURL: "", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "", AuthorizationKey: "12345"}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "missing-authorization-key", Name: "missing-authorization-key",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/whatever/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: ""}},
Expected: false, ExpectedError: true,
}, },
{ {
Name: "invalid-url", Name: "invalid-url",
Provider: AlertProvider{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: " http://foo.com", AuthorizationKey: "12345"}},
Expected: false, ExpectedError: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if scenario.Provider.IsValid() != scenario.Expected { err := scenario.Provider.Validate()
t.Errorf("expected %t, got %t", scenario.Expected, scenario.Provider.IsValid()) if scenario.ExpectedError && err == nil {
t.Error("expected error, got none")
}
if !scenario.ExpectedError && err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Error("expected no error, got", err.Error())
} }
}) })
} }
@@ -61,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: false, ExpectedError: false,
@@ -71,7 +75,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: false, ExpectedError: false,
@@ -116,21 +120,26 @@ func TestAlertProvider_buildAlertBody(t *testing.T) {
{ {
Name: "triggered", Name: "triggered",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, FailureThreshold: 3},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 3 time(s) in a row:\\n\\u003e description-1\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
}, },
{ {
Name: "no-description", Name: "no-description",
Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"}, Endpoint: endpoint.Endpoint{Name: "endpoint-name", URL: "https://example.org"},
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "https://gitlab.com/hlidotbe/text/alerts/notify/gatus/xxxxxxxxxxxxxxxx.json", AuthorizationKey: "12345"}},
Alert: alert.Alert{FailureThreshold: 10}, Alert: alert.Alert{FailureThreshold: 10},
ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\"}", ExpectedBody: "{\"title\":\"alert(gatus): endpoint-name\",\"description\":\"An alert for *endpoint-name* has been triggered due to having failed 10 time(s) in a row\\n\\n## Condition results\\n- :white_check_mark: - `[CONNECTED] == true`\\n- :x: - `[STATUS] == 200`\\n\",\"start_time\":\"0001-01-01T00:00:00Z\",\"service\":\"endpoint-name\",\"monitoring_tool\":\"gatus\",\"hosts\":\"https://example.org\",\"severity\":\"critical\"}",
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildAlertBody( body := scenario.Provider.buildAlertBody(
cfg,
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -156,3 +165,59 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345", Severity: DefaultSeverity, MonitoringTool: DefaultMonitoringTool},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"repository-url": "https://github.com/TwiN/alert-test", "authorization-key": "54321", "severity": "info", "monitoring-tool": "not-gatus", "environment-name": "prod", "service": "example"}},
ExpectedOutput: Config{WebhookURL: "https://github.com/TwiN/test", AuthorizationKey: "54321", Severity: "info", MonitoringTool: "not-gatus", EnvironmentName: "prod", Service: "example"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil && !strings.Contains(err.Error(), "user does not exist") && !strings.Contains(err.Error(), "no such host") {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected repository URL %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
if got.AuthorizationKey != scenario.ExpectedOutput.AuthorizationKey {
t.Errorf("expected AuthorizationKey %s, got %s", scenario.ExpectedOutput.AuthorizationKey, got.AuthorizationKey)
}
if got.Severity != scenario.ExpectedOutput.Severity {
t.Errorf("expected Severity %s, got %s", scenario.ExpectedOutput.Severity, got.Severity)
}
if got.MonitoringTool != scenario.ExpectedOutput.MonitoringTool {
t.Errorf("expected MonitoringTool %s, got %s", scenario.ExpectedOutput.MonitoringTool, got.MonitoringTool)
}
if got.EnvironmentName != scenario.ExpectedOutput.EnvironmentName {
t.Errorf("expected EnvironmentName %s, got %s", scenario.ExpectedOutput.EnvironmentName, got.EnvironmentName)
}
if got.Service != scenario.ExpectedOutput.Service {
t.Errorf("expected Service %s, got %s", scenario.ExpectedOutput.Service, got.Service)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+72 -27
View File
@@ -3,6 +3,7 @@ package googlechat
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,14 +11,38 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
}
// AlertProvider is the configuration necessary for sending an alert using Google chat // AlertProvider is the configuration necessary for sending an alert using Google chat
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -28,36 +53,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -185,19 +211,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON return bodyAsJSON
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+69 -41
View File
@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -213,64 +213,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// 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)
} }
}) })
} }
+78 -23
View File
@@ -3,6 +3,7 @@ package gotify
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,40 +11,72 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const DefaultPriority = 5 const DefaultPriority = 5
var (
ErrServerURLNotSet = errors.New("server URL not set")
ErrTokenNotSet = errors.New("token not set")
)
type Config struct {
ServerURL string `yaml:"server-url"` // URL of the Gotify server
Token string `yaml:"token"` // Token to use when sending a message to the Gotify server
Priority int `yaml:"priority,omitempty"` // Priority of the message. Defaults to DefaultPriority.
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
func (cfg *Config) Validate() error {
if cfg.Priority == 0 {
cfg.Priority = DefaultPriority
}
if len(cfg.ServerURL) == 0 {
return ErrServerURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ServerURL) > 0 {
cfg.ServerURL = override.ServerURL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if override.Priority != 0 {
cfg.Priority = override.Priority
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Gotify // AlertProvider is the configuration necessary for sending an alert using Gotify
type AlertProvider struct { type AlertProvider struct {
// ServerURL is the URL of the Gotify server DefaultConfig Config `yaml:",inline"`
ServerURL string `yaml:"server-url"`
// Token is the token to use when sending a message to the Gotify server
Token string `yaml:"token"`
// Priority is the priority of the message
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.Priority == 0 { return provider.DefaultConfig.Validate()
provider.Priority = DefaultPriority
}
return len(provider.ServerURL) > 0 && len(provider.Token) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.ServerURL+"/message?token="+provider.Token, buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.ServerURL+"/message?token="+cfg.Token, buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -67,7 +100,7 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { 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) message = fmt.Sprintf("An alert for `%s` has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@@ -89,13 +122,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
} }
message += formattedConditionResults message += formattedConditionResults
title := "Gatus: " + ep.DisplayName() title := "Gatus: " + ep.DisplayName()
if provider.Title != "" { if cfg.Title != "" {
title = provider.Title title = cfg.Title
} }
bodyAsJSON, _ := json.Marshal(Body{ bodyAsJSON, _ := json.Marshal(Body{
Message: message, Message: message,
Title: title, Title: title,
Priority: provider.Priority, Priority: cfg.Priority,
}) })
return bodyAsJSON return bodyAsJSON
} }
@@ -104,3 +137,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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
}
+68 -10
View File
@@ -9,7 +9,7 @@ import (
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
provider AlertProvider provider AlertProvider
@@ -17,29 +17,29 @@ func TestAlertProvider_IsValid(t *testing.T) {
}{ }{
{ {
name: "valid", name: "valid",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true, expected: true,
}, },
{ {
name: "invalid-server-url", name: "invalid-server-url",
provider: AlertProvider{ServerURL: "", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "", Token: "faketoken"}},
expected: false, expected: false,
}, },
{ {
name: "invalid-app-token", name: "invalid-app-token",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: ""}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: ""}},
expected: false, expected: false,
}, },
{ {
name: "no-priority-should-use-default-value", name: "no-priority-should-use-default-value",
provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
expected: true, expected: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected { if err := scenario.provider.Validate(); (err == nil) != scenario.expected {
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid()) t.Errorf("expected: %t, got: %t", scenario.expected, err == nil)
} }
}) })
} }
@@ -60,21 +60,21 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been resolved after passing successfully 5 time(s) in a row with the following description: %s\\n✓ - [CONNECTED] == true\\n✓ - [STATUS] == 200\",\"title\":\"Gatus: custom-endpoint\",\"priority\":0}", endpointName, description),
}, },
{ {
Name: "custom-title", Name: "custom-title",
Provider: AlertProvider{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}, Provider: AlertProvider{DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "faketoken", Title: "custom-title"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description), ExpectedBody: fmt.Sprintf("{\"message\":\"An alert for `%s` has been triggered due to having failed 3 time(s) in a row with the following description: %s\\n✕ - [CONNECTED] == true\\n✕ - [STATUS] == 200\",\"title\":\"custom-title\",\"priority\":0}", endpointName, description),
@@ -83,6 +83,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: endpointName}, &endpoint.Endpoint{Name: endpointName},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -103,3 +104,60 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
provider := AlertProvider{DefaultAlert: &alert.Alert{}}
if provider.GetDefaultAlert() != provider.DefaultAlert {
t.Error("expected default alert to be returned")
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{ServerURL: "https://gotify.example.com", Token: "12345", Priority: DefaultPriority},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{ServerURL: "https://gotify.example.com", Token: "12345"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://gotify.group-example.com", "token": "54321", "title": "alert-title", "priority": 3}},
ExpectedOutput: Config{ServerURL: "https://gotify.group-example.com", Token: "54321", Title: "alert-title", Priority: 3},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.ServerURL != scenario.ExpectedOutput.ServerURL {
t.Errorf("expected server URL to be %s, got %s", scenario.ExpectedOutput.ServerURL, got.ServerURL)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
if got.Title != scenario.ExpectedOutput.Title {
t.Errorf("expected title to be %s, got %s", scenario.ExpectedOutput.Title, got.Title)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected priority to be %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
@@ -0,0 +1,196 @@
package homeassistant
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 (
ErrURLNotSet = errors.New("url not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
URL string `yaml:"url"`
Token string `yaml:"token"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using HomeAssistant
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
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/api/events/gatus_alert", cfg.URL), buffer)
if err != nil {
return err
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+cfg.Token)
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
EventType string `json:"event_type"`
EventData struct {
Status string `json:"status"`
Endpoint string `json:"endpoint"`
Description string `json:"description,omitempty"`
Conditions []struct {
Condition string `json:"condition"`
Success bool `json:"success"`
} `json:"conditions,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
SuccessCount int `json:"success_count,omitempty"`
} `json:"event_data"`
}
// buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{
EventType: "gatus_alert",
EventData: struct {
Status string `json:"status"`
Endpoint string `json:"endpoint"`
Description string `json:"description,omitempty"`
Conditions []struct {
Condition string `json:"condition"`
Success bool `json:"success"`
} `json:"conditions,omitempty"`
FailureCount int `json:"failure_count,omitempty"`
SuccessCount int `json:"success_count,omitempty"`
}{
Status: "resolved",
Endpoint: ep.DisplayName(),
},
}
if !resolved {
body.EventData.Status = "triggered"
body.EventData.FailureCount = alert.FailureThreshold
} else {
body.EventData.SuccessCount = alert.SuccessThreshold
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
body.EventData.Description = alertDescription
}
if len(result.ConditionResults) > 0 {
for _, conditionResult := range result.ConditionResults {
body.EventData.Conditions = append(body.EventData.Conditions, struct {
Condition string `json:"condition"`
Success bool `json:"success"`
}{
Condition: conditionResult.Condition,
Success: conditionResult.Success,
})
}
}
bodyAsJSON, _ := json.Marshal(body)
return bodyAsJSON
}
// 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
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
@@ -0,0 +1,158 @@
package homeassistant
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{URL: "", Token: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
invalidProviderNoToken := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: ""}}
if err := invalidProviderNoToken.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
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{URL: "http://homeassistant:8123", Token: "token"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"},
Overrides: []Override{
{
Config: Config{URL: "http://homeassistant:8123", Token: "token"},
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{URL: "http://homeassistant:8123", Token: "token"}},
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{URL: "http://homeassistant:8123", Token: "token"}},
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{URL: "http://homeassistant:8123", Token: "token"}},
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,
},
}
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: "SUCCESSFUL_CONDITION", Success: true},
{Condition: "FAILING_CONDITION", Success: false},
},
},
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) {
description := "test-description"
provider := AlertProvider{DefaultConfig: Config{URL: "http://homeassistant:8123", Token: "token"}}
body := provider.buildRequestBody(
&endpoint.Endpoint{Name: "endpoint-name"},
&alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "SUCCESSFUL_CONDITION", Success: true},
{Condition: "FAILING_CONDITION", Success: false},
},
},
false,
)
var decodedBody Body
if err := json.Unmarshal(body, &decodedBody); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
if decodedBody.EventType != "gatus_alert" {
t.Errorf("expected event_type to be gatus_alert, got %s", decodedBody.EventType)
}
if decodedBody.EventData.Status != "triggered" {
t.Errorf("expected status to be triggered, got %s", decodedBody.EventData.Status)
}
if decodedBody.EventData.Description != description {
t.Errorf("expected description to be %s, got %s", description, decodedBody.EventData.Description)
}
if len(decodedBody.EventData.Conditions) != 2 {
t.Errorf("expected 2 conditions, got %d", len(decodedBody.EventData.Conditions))
}
if !decodedBody.EventData.Conditions[0].Success {
t.Error("expected first condition to be successful")
}
if decodedBody.EventData.Conditions[1].Success {
t.Error("expected second condition to be unsuccessful")
}
}
+168
View File
@@ -0,0 +1,168 @@
package ilert
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"
)
const (
restAPIUrl = "https://api.ilert.com/api/v1/events/gatus/"
)
var (
ErrIntegrationKeyNotSet = errors.New("integration key is not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
IntegrationKey string `yaml:"integration-key"`
}
func (cfg *Config) Validate() error {
if len(cfg.IntegrationKey) == 0 {
return ErrIntegrationKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.IntegrationKey) > 0 {
cfg.IntegrationKey = override.IntegrationKey
}
}
// AlertProvider is the configuration necessary for sending an alert using ilert
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"`
}
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
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()
}
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
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s%s", restAPIUrl, cfg.IntegrationKey), buffer)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
type Body struct {
Alert alert.Alert `json:"alert"`
Name string `json:"name"`
Group string `json:"group"`
Status string `json:"status"`
Title string `json:"title"`
Details string `json:"details,omitempty"`
ConditionResults []*endpoint.ConditionResult `json:"condition_results"`
URL string `json:"url"`
}
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var details, status string
if resolved {
status = "resolved"
} else {
status = "firing"
}
if len(alert.GetDescription()) > 0 {
details = alert.GetDescription()
} else {
details = "No description"
}
var body []byte
body, _ = json.Marshal(Body{
Alert: *alert,
Name: ep.Name,
Group: ep.Group,
Title: ep.DisplayName(),
Status: status,
Details: details,
ConditionResults: result.ConditionResults,
URL: ep.URL,
})
return body
}
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
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
}
+322
View File
@@ -0,0 +1,322 @@
package ilert
import (
"bytes"
"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 TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{
DefaultConfig: Config{
IntegrationKey: "some-random-key",
},
},
expected: true,
},
{
name: "invalid-integration-key",
provider: AlertProvider{
DefaultConfig: Config{
IntegrationKey: "",
},
},
expected: false,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
}
})
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid, got error:", err.Error())
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
sendOnResolved := true
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
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,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
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,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusBadRequest, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{
IntegrationKey: "some-integration-key",
}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 3, FailureThreshold: 3, ResolveKey: "123", Type: "ilert", SendOnResolved: &sendOnResolved},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
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_BuildRequestBody(t *testing.T) {
firstDescription := "description-1"
secondDescription := "description-2"
sendOnResolved := true
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
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,"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,"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,"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":""}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&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 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{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
},
}
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.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
}
// 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)
}
})
}
}
+210
View File
@@ -0,0 +1,210 @@
package incidentio
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
)
const (
restAPIUrl = "https://api.incident.io/v2/alert_events/http/"
)
var (
ErrURLNotSet = errors.New("url not set")
ErrURLNotPrefixedWithRestAPIURL = fmt.Errorf("url must be prefixed with %s", restAPIUrl)
ErrDuplicateGroupOverride = errors.New("duplicate group override")
ErrAuthTokenNotSet = errors.New("auth-token not set")
)
type Config struct {
URL string `yaml:"url,omitempty"`
AuthToken string `yaml:"auth-token,omitempty"`
SourceURL string `yaml:"source-url,omitempty"`
Metadata map[string]interface{} `yaml:"metadata,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
return ErrURLNotSet
}
if !strings.HasPrefix(cfg.URL, restAPIUrl) {
return ErrURLNotPrefixedWithRestAPIURL
}
if len(cfg.AuthToken) == 0 {
return ErrAuthTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if len(override.AuthToken) > 0 {
cfg.AuthToken = override.AuthToken
}
if len(override.SourceURL) > 0 {
cfg.SourceURL = override.SourceURL
}
if len(override.Metadata) > 0 {
cfg.Metadata = override.Metadata
}
}
// AlertProvider is the configuration necessary for sending an alert using incident.io
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"`
}
type Override struct {
Group string `yaml:"group"`
Config `yaml:",inline"`
}
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()
}
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
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
req, err := http.NewRequest(http.MethodPost, cfg.URL, buffer)
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+cfg.AuthToken)
response, err := client.GetHTTPClient(nil).Do(req)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
incidentioResponse := Response{}
err = json.NewDecoder(response.Body).Decode(&incidentioResponse)
if err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
logr.Errorf("[incidentio.Send] Ran into error decoding pagerduty response: %s", err.Error())
}
alert.ResolveKey = incidentioResponse.DeduplicationKey
return err
}
type Body struct {
AlertSourceConfigID string `json:"alert_source_config_id"`
Status string `json:"status"`
Title string `json:"title"`
DeduplicationKey string `json:"deduplication_key,omitempty"`
Description string `json:"description,omitempty"`
SourceURL string `json:"source_url,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
}
type Response struct {
DeduplicationKey string `json:"deduplication_key"`
}
func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults, status string
if resolved {
message = "An alert has been resolved after passing successfully " + strconv.Itoa(alert.SuccessThreshold) + " time(s) in a row"
status = "resolved"
} else {
message = "An alert has been triggered due to having failed " + strconv.Itoa(alert.FailureThreshold) + " time(s) in a row"
status = "firing"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "🟢"
} else {
prefix = "🔴"
}
// No need for \n since incident.io trims it anyways.
formattedConditionResults += fmt.Sprintf(" %s %s ", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += fmt.Sprintf(" and the following conditions: %s ", formattedConditionResults)
var body []byte
alertSourceID := strings.TrimPrefix(cfg.URL, restAPIUrl)
body, _ = json.Marshal(Body{
AlertSourceConfigID: alertSourceID,
Title: "Gatus: " + ep.DisplayName(),
Status: status,
DeduplicationKey: alert.ResolveKey,
Description: message,
SourceURL: cfg.SourceURL,
Metadata: cfg.Metadata,
})
fmt.Printf("%v", string(body))
return body
}
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
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
@@ -0,0 +1,390 @@
package incidentio
import (
"bytes"
"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 TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct {
name string
provider AlertProvider
expected bool
}{
{
name: "valid",
provider: AlertProvider{
DefaultConfig: Config{
URL: "https://api.incident.io/v2/alert_events/http/some-id",
AuthToken: "some-token",
},
},
expected: true,
},
{
name: "invalid-url",
provider: AlertProvider{
DefaultConfig: Config{
URL: "id-without-rest-api-url-as-prefix",
AuthToken: "some-token",
},
},
expected: false,
},
{
name: "invalid-missing-auth-token",
provider: AlertProvider{
DefaultConfig: Config{
URL: "some-id",
},
},
expected: false,
},
{
name: "invalid-missing-alert-source-config-id",
provider: AlertProvider{
DefaultConfig: Config{
AuthToken: "some-token",
},
},
expected: false,
},
{
name: "valid-override",
provider: AlertProvider{
DefaultConfig: Config{
AuthToken: "some-token",
URL: "https://api.incident.io/v2/alert_events/http/some-id",
},
Overrides: []Override{{Group: "core", Config: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id"}}},
},
expected: true,
},
}
for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) {
err := scenario.provider.Validate()
if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
}
})
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
firstDescription := "description-1"
secondDescription := "description-2"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
MockRoundTripper test.MockRoundTripper
ExpectedError bool
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{
URL: restAPIUrl + "some-id",
AuthToken: "some-token",
}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
response := Response{DeduplicationKey: "some-key"}
json.NewEncoder(&b).Encode(response)
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{
URL: restAPIUrl + "some-id",
AuthToken: "some-token",
}},
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{
URL: restAPIUrl + "some-id",
AuthToken: "some-token",
}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
var b bytes.Buffer
response := Response{DeduplicationKey: "some-key"}
json.NewEncoder(&b).Encode(response)
reader := io.NopCloser(&b)
return &http.Response{StatusCode: http.StatusAccepted, Body: reader}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{}},
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"
restAPIUrl := "https://api.incident.io/v2/alert_events/http/"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 "}`,
},
{
Name: "resolved-with-metadata-source-url",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token", Metadata: map[string]interface{}{"service": "some-service", "team": "very-core"}, SourceURL: "some-source-url"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"alert_source_config_id":"some-id","status":"resolved","title":"Gatus: endpoint-name","description":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2 and the following conditions: 🟢 [CONNECTED] == true 🟢 [STATUS] == 200 ","source_url":"some-source-url","metadata":{"service":"some-service","team":"very-core"}}`,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: restAPIUrl + "some-id", AuthToken: "some-token"}, Overrides: []Override{{Group: "g", Config: Config{URL: restAPIUrl + "different-id", AuthToken: "some-token"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"alert_source_config_id":"different-id","status":"firing","title":"Gatus: endpoint-name","description":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1 and the following conditions: 🔴 [CONNECTED] == true 🔴 [STATUS] == 200 "}`,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody(
cfg,
&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 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{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/diff-id", AuthToken: "some-token"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "https://api.incident.io/v2/alert_events/http/another-id"}},
ExpectedOutput: Config{URL: "https://api.incident.io/v2/alert_events/http/another-id", AuthToken: "some-token"},
},
}
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.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected alert source config to be %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.AuthToken != scenario.ExpectedOutput.AuthToken {
t.Errorf("expected alert auth token to be %s, got %s", scenario.ExpectedOutput.AuthToken, got.AuthToken)
}
// 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_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/some-id", AuthToken: "some-token"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{URL: "", AuthToken: "some-token"},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{URL: "https://api.incident.io/v2/alert_events/http/nice-id", AuthToken: "some-token"},
Overrides: []Override{
{
Config: Config{URL: "https://api.incident.io/v2/alert_events/http/very-good-id", AuthToken: "some-token"},
Group: "group",
},
},
}
if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid")
}
}
@@ -3,6 +3,7 @@ package jetbrainsspace
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,13 +11,50 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
var (
ErrProjectNotSet = errors.New("project not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrTokenNotSet = errors.New("token not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Project string `yaml:"project"` // Project name
ChannelID string `yaml:"channel-id"` // Chat Channel ID
Token string `yaml:"token"` // Bearer Token
}
func (cfg *Config) Validate() error {
if len(cfg.Project) == 0 {
return ErrProjectNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Project) > 0 {
cfg.Project = override.Project
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
}
// AlertProvider is the configuration necessary for sending an alert using JetBrains Space // AlertProvider is the configuration necessary for sending an alert using JetBrains Space
type AlertProvider struct { type AlertProvider struct {
Project string `yaml:"project"` // JetBrains Space Project name DefaultConfig Config `yaml:",inline"`
ChannelID string `yaml:"channel-id"` // JetBrains Space Chat Channel ID
Token string `yaml:"token"` // JetBrains Space Bearer Token
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -27,34 +65,38 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
ChannelID string `yaml:"channel-id"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.ChannelID) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.Project) > 0 && len(provider.ChannelID) > 0 && len(provider.Token) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", provider.Project) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := fmt.Sprintf("https://%s.jetbrains.space/api/http/chats/messages/send-message", cfg.Project)
request, err := http.NewRequest(http.MethodPost, url, buffer) request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "Bearer "+provider.Token) request.Header.Set("Authorization", "Bearer "+cfg.Token)
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -103,9 +145,9 @@ type Icon struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
body := Body{ body := Body{
Channel: "id:" + provider.getChannelIDForGroup(ep.Group), Channel: "id:" + cfg.ChannelID,
Content: Content{ Content: Content{
ClassName: "ChatMessage.Block", ClassName: "ChatMessage.Block",
Sections: []Section{{ Sections: []Section{{
@@ -144,19 +186,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON return bodyAsJSON
} }
// getChannelIDForGroup returns the appropriate channel ID to for a given group override
func (provider *AlertProvider) getChannelIDForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.ChannelID
}
}
}
return provider.ChannelID
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
@@ -11,54 +11,56 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{Project: ""} invalidProvider := AlertProvider{DefaultConfig: Config{Project: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{Project: "foo", ChannelID: "bar", Token: "baz"} validProvider := AlertProvider{DefaultConfig: Config{Project: "foo", ChannelID: "bar", Token: "baz"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Project: "foobar", DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "http://example.com", Config: Config{ChannelID: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Project: "foobar", DefaultConfig: Config{Project: "foobar"},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "", Config: Config{ChannelID: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
Project: "foo", DefaultConfig: Config{
ChannelID: "bar", Project: "foo",
Token: "baz", ChannelID: "bar",
Token: "baz",
},
Overrides: []Override{ Overrides: []Override{
{ {
ChannelID: "foobar", Config: Config{ChannelID: "foobar"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -77,7 +79,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -87,7 +89,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -97,7 +99,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -107,7 +109,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project", Token: "token"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -153,40 +155,41 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
}, },
{ {
Name: "triggered-with-group", Name: "triggered-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"WARNING","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"warning"},"style":"WARNING"},"style":"WARNING","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been triggered due to having failed 3 time(s) in a row"}]}}`,
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name"}, Endpoint: endpoint.Endpoint{Name: "name"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
}, },
{ {
Name: "resolved-with-group", Name: "resolved-with-group",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ChannelID: "1", Project: "project"}},
Endpoint: endpoint.Endpoint{Name: "name", Group: "group"}, Endpoint: endpoint.Endpoint{Name: "name", Group: "group"},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: `{"channel":"id:","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`, ExpectedBody: `{"channel":"id:1","content":{"className":"ChatMessage.Block","style":"SUCCESS","sections":[{"className":"MessageSection","elements":[{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[CONNECTED] == true"},{"className":"MessageText","accessory":{"className":"MessageIcon","icon":{"icon":"success"},"style":"SUCCESS"},"style":"SUCCESS","size":"REGULAR","content":"[STATUS] == 200"}],"header":"An alert for *group/name* has been resolved after passing successfully 5 time(s) in a row"}]}}`,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&scenario.Endpoint, &scenario.Endpoint,
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -217,62 +220,98 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getChannelIDForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ChannelID: "foobar", Config: Config{ChannelID: "group-channel"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "bar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "default", Project: "project", Token: "token"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
ChannelID: "bar", DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ChannelID: "foobar", Config: Config{ChannelID: "group-channel"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "foobar", InputAlert: alert.Alert{},
ExpectedOutput: Config{ChannelID: "group-channel", Project: "project", Token: "token"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{ChannelID: "default", Project: "project", Token: "token"},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"channel-id": "alert-channel", "project": "alert-project", "token": "alert-token"}},
ExpectedOutput: Config{ChannelID: "alert-channel", Project: "alert-project", Token: "alert-token"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getChannelIDForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getChannelIDForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
if got.Project != scenario.ExpectedOutput.Project {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Project, got.Project)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
// 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)
} }
}) })
} }
+93 -44
View File
@@ -3,39 +3,28 @@ package matrix
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
"net/http" "net/http"
"net/url" "net/url"
"time"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Matrix
type AlertProvider struct {
ProviderConfig `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"`
ProviderConfig `yaml:",inline"`
}
const defaultServerURL = "https://matrix-client.matrix.org" const defaultServerURL = "https://matrix-client.matrix.org"
type ProviderConfig struct { var (
ErrAccessTokenNotSet = errors.New("access-token not set")
ErrInternalRoomID = errors.New("internal-room-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
// ServerURL is the custom homeserver to use (optional) // ServerURL is the custom homeserver to use (optional)
ServerURL string `yaml:"server-url"` ServerURL string `yaml:"server-url"`
@@ -46,36 +35,78 @@ type ProviderConfig struct {
InternalRoomID string `yaml:"internal-room-id"` InternalRoomID string `yaml:"internal-room-id"`
} }
// IsValid returns whether the provider's configuration is valid func (cfg *Config) Validate() error {
func (provider *AlertProvider) IsValid() bool { if len(cfg.ServerURL) == 0 {
cfg.ServerURL = defaultServerURL
}
if len(cfg.AccessToken) == 0 {
return ErrAccessTokenNotSet
}
if len(cfg.InternalRoomID) == 0 {
return ErrInternalRoomID
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ServerURL) > 0 {
cfg.ServerURL = override.ServerURL
}
if len(override.AccessToken) > 0 {
cfg.AccessToken = override.AccessToken
}
if len(override.InternalRoomID) > 0 {
cfg.InternalRoomID = override.InternalRoomID
}
}
// AlertProvider is the configuration necessary for sending an alert using Matrix
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) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.AccessToken) == 0 || len(override.InternalRoomID) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.AccessToken) > 0 && len(provider.InternalRoomID) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
config := provider.getConfigForGroup(ep.Group) if err != nil {
if config.ServerURL == "" { return err
config.ServerURL = defaultServerURL
} }
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
// The Matrix endpoint requires a unique transaction ID for each event sent // The Matrix endpoint requires a unique transaction ID for each event sent
txnId := randStringBytes(24) txnId := randStringBytes(24)
request, err := http.NewRequest( request, err := http.NewRequest(
http.MethodPut, http.MethodPut,
fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s", fmt.Sprintf("%s/_matrix/client/v3/rooms/%s/send/m.room.message/%s?access_token=%s",
config.ServerURL, cfg.ServerURL,
url.PathEscape(config.InternalRoomID), url.PathEscape(cfg.InternalRoomID),
txnId, txnId,
url.QueryEscape(config.AccessToken), url.QueryEscape(cfg.AccessToken),
), ),
buffer, buffer,
) )
@@ -167,23 +198,10 @@ func buildHTMLMessageBody(ep *endpoint.Endpoint, alert *alert.Alert, result *end
return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults) return fmt.Sprintf("<h3>%s</h3>%s%s", message, description, formattedConditionResults)
} }
// getConfigForGroup returns the appropriate configuration for a given group
func (provider *AlertProvider) getConfigForGroup(group string) ProviderConfig {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.ProviderConfig
}
}
}
return provider.ProviderConfig
}
func randStringBytes(n int) string { func randStringBytes(n int) string {
// All the compatible characters to use in a transaction ID // All the compatible characters to use in a transaction ID
const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" const availableCharacterBytes = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
b := make([]byte, n) b := make([]byte, n)
rand.Seed(time.Now().UnixNano())
for i := range b { for i := range b {
b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))] b[i] = availableCharacterBytes[rand.Intn(len(availableCharacterBytes))]
} }
@@ -194,3 +212,34 @@ func randStringBytes(n int) string {
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+98 -42
View File
@@ -11,75 +11,75 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{ invalidProvider := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
} }
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
validProviderWithHomeserver := AlertProvider{ validProviderWithHomeserver := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
} }
if !validProviderWithHomeserver.IsValid() { if err := validProviderWithHomeserver.Validate(); err != nil {
t.Error("provider with homeserver should've been valid") t.Error("provider with homeserver should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Group: "", Group: "",
ProviderConfig: ProviderConfig{ Config: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
AccessToken: "", AccessToken: "",
InternalRoomID: "", InternalRoomID: "",
}, },
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
}, },
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -87,7 +87,7 @@ func TestAlertProvider_IsValidWithOverride(t *testing.T) {
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -105,18 +105,28 @@ func TestAlertProvider_Send(t *testing.T) {
ExpectedError bool ExpectedError bool
}{ }{
{ {
Name: "triggered", Name: "triggered-with-bad-config",
Provider: AlertProvider{}, Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody} return &http.Response{StatusCode: http.StatusOK, Body: http.NoBody}
}), }),
ExpectedError: true,
},
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a: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.StatusOK, Body: http.NoBody}
}),
ExpectedError: false, ExpectedError: false,
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -126,7 +136,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -136,7 +146,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessToken: "1", InternalRoomID: "!a:example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -227,17 +237,18 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getConfigForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput ProviderConfig InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -245,7 +256,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -254,7 +266,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -262,7 +274,8 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -271,7 +284,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -279,16 +292,17 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example01.com", ServerURL: "https://group-example.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:group-example.com",
}, },
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -297,7 +311,7 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
ProviderConfig: ProviderConfig{ DefaultConfig: Config{
ServerURL: "https://example.com", ServerURL: "https://example.com",
AccessToken: "1", AccessToken: "1",
InternalRoomID: "!a:example.com", InternalRoomID: "!a:example.com",
@@ -305,8 +319,35 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
ProviderConfig: ProviderConfig{ Config: Config{
ServerURL: "https://example01.com", ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
ServerURL: "https://group-example.com",
AccessToken: "12",
InternalRoomID: "!a:group-example.com",
},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{
ServerURL: "https://example.com",
AccessToken: "1",
InternalRoomID: "!a:example.com",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
ServerURL: "https://group-example.com",
AccessToken: "12", AccessToken: "12",
InternalRoomID: "!a:example01.com", InternalRoomID: "!a:example01.com",
}, },
@@ -314,17 +355,32 @@ func TestAlertProvider_getConfigForGroup(t *testing.T) {
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: ProviderConfig{ InputAlert: alert.Alert{ProviderOverride: map[string]any{"server-url": "https://alert-example.com", "access-token": "123", "internal-room-id": "!a:alert-example.com"}},
ServerURL: "https://example01.com", ExpectedOutput: Config{
AccessToken: "12", ServerURL: "https://alert-example.com",
InternalRoomID: "!a:example01.com", AccessToken: "123",
InternalRoomID: "!a:alert-example.com",
}, },
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getConfigForGroup(tt.InputGroup); got != tt.ExpectedOutput { outputConfig, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getConfigForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Errorf("expected no error, got %v", err)
}
if outputConfig.ServerURL != scenario.ExpectedOutput.ServerURL {
t.Errorf("expected ServerURL to be %s, got %s", scenario.ExpectedOutput.ServerURL, outputConfig.ServerURL)
}
if outputConfig.AccessToken != scenario.ExpectedOutput.AccessToken {
t.Errorf("expected AccessToken to be %s, got %s", scenario.ExpectedOutput.AccessToken, outputConfig.AccessToken)
}
if outputConfig.InternalRoomID != scenario.ExpectedOutput.InternalRoomID {
t.Errorf("expected InternalRoomID to be %s, got %s", scenario.ExpectedOutput.InternalRoomID, outputConfig.InternalRoomID)
}
// 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)
} }
}) })
} }
+80 -29
View File
@@ -3,6 +3,7 @@ package mattermost
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,14 +11,42 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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"`
Channel string `yaml:"channel,omitempty"`
ClientConfig *client.Config `yaml:"client,omitempty"`
}
func (cfg *Config) Validate() error {
if len(cfg.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
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 Mattermost // AlertProvider is the configuration necessary for sending an alert using Mattermost
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -28,36 +57,37 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.ClientConfig == nil {
provider.ClientConfig = client.GetDefaultConfig()
}
if provider.Overrides != nil { if provider.Overrides != nil {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved))) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -70,6 +100,7 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
} }
type Body struct { type Body struct {
Channel string `json:"channel,omitempty"` // Optional channel override
Text string `json:"text"` Text string `json:"text"`
Username string `json:"username"` Username string `json:"username"`
IconURL string `json:"icon_url"` IconURL string `json:"icon_url"`
@@ -92,7 +123,7 @@ type Field struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { 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) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@@ -118,6 +149,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
description = ":\n> " + alertDescription description = ":\n> " + alertDescription
} }
body := Body{ body := Body{
Channel: cfg.Channel,
Text: "", Text: "",
Username: "gatus", Username: "gatus",
IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png", IconURL: "https://raw.githubusercontent.com/TwiN/gatus/master/.github/assets/logo.png",
@@ -142,19 +174,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON return bodyAsJSON
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+70 -45
View File
@@ -11,54 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
if providerWithInvalidOverrideGroup.IsValid() {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideWebHookUrl := AlertProvider{ providerWithInvalidOverrideWebHookUrl := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
Config: Config{WebhookURL: ""},
WebhookURL: "", Group: "group",
Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideWebHookUrl.IsValid() { if err := providerWithInvalidOverrideWebHookUrl.Validate(); err == nil {
t.Error("provider WebHookURL shouldn't have been valid") t.Error("provider WebHookURL shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -77,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -87,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -97,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -107,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -168,6 +164,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -198,64 +195,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// 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)
} }
}) })
} }
+73 -12
View File
@@ -3,6 +3,7 @@ package messagebird
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,37 +11,75 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const restAPIURL = "https://rest.messagebird.com/messages"
restAPIURL = "https://rest.messagebird.com/messages"
var (
ErrorAccessKeyNotSet = errors.New("access-key not set")
ErrorOriginatorNotSet = errors.New("originator not set")
ErrorRecipientsNotSet = errors.New("recipients not set")
) )
// AlertProvider is the configuration necessary for sending an alert using Messagebird type Config struct {
type AlertProvider struct {
AccessKey string `yaml:"access-key"` AccessKey string `yaml:"access-key"`
Originator string `yaml:"originator"` Originator string `yaml:"originator"`
Recipients string `yaml:"recipients"` Recipients string `yaml:"recipients"`
}
func (cfg *Config) Validate() error {
if len(cfg.AccessKey) == 0 {
return ErrorAccessKeyNotSet
}
if len(cfg.Originator) == 0 {
return ErrorOriginatorNotSet
}
if len(cfg.Recipients) == 0 {
return ErrorRecipientsNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.AccessKey) > 0 {
cfg.AccessKey = override.AccessKey
}
if len(override.Originator) > 0 {
cfg.Originator = override.Originator
}
if len(override.Recipients) > 0 {
cfg.Recipients = override.Recipients
}
}
// AlertProvider is the configuration necessary for sending an alert using Messagebird
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.AccessKey) > 0 && len(provider.Originator) > 0 && len(provider.Recipients) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
// Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms // Reference doc for messagebird: https://developers.messagebird.com/api/sms-messaging/#send-outbound-sms
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
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, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", provider.AccessKey)) request.Header.Set("Authorization", fmt.Sprintf("AccessKey %s", cfg.AccessKey))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -60,7 +99,7 @@ type Body struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@@ -68,8 +107,8 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Originator: provider.Originator, Originator: cfg.Originator,
Recipients: provider.Recipients, Recipients: cfg.Recipients,
Body: message, Body: message,
}) })
return body return body
@@ -79,3 +118,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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
}
@@ -13,15 +13,17 @@ import (
func TestMessagebirdAlertProvider_IsValid(t *testing.T) { func TestMessagebirdAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
AccessKey: "1", DefaultConfig: Config{
Originator: "1", AccessKey: "1",
Recipients: "1", Originator: "1",
Recipients: "1",
},
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -40,7 +42,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -50,7 +52,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -60,7 +62,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -70,7 +72,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -115,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{AccessKey: "1", Originator: "2", Recipients: "3"}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}", ExpectedBody: "{\"originator\":\"2\",\"recipients\":\"3\",\"body\":\"TRIGGERED: endpoint-name - description-1\"}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{AccessKey: "4", Originator: "5", Recipients: "6"}, Provider: AlertProvider{DefaultConfig: Config{AccessKey: "4", Originator: "5", Recipients: "6"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}", ExpectedBody: "{\"originator\":\"5\",\"recipients\":\"6\",\"body\":\"RESOLVED: endpoint-name - description-2\"}",
@@ -131,6 +133,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -145,7 +148,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
out := make(map[string]interface{}) out := make(map[string]interface{})
if err := json.Unmarshal([]byte(body), &out); err != nil { if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error()) t.Error("expected body to be valid JSON, got error:", err.Error())
} }
}) })
@@ -160,3 +163,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{AccessKey: "1", Originator: "2", Recipients: "3"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"access-key": "4", "originator": "5", "recipients": "6"}},
ExpectedOutput: Config{AccessKey: "4", Originator: "5", Recipients: "6"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.AccessKey != scenario.ExpectedOutput.AccessKey {
t.Errorf("expected access key to be %s, got %s", scenario.ExpectedOutput.AccessKey, got.AccessKey)
}
if got.Originator != scenario.ExpectedOutput.Originator {
t.Errorf("expected originator to be %s, got %s", scenario.ExpectedOutput.Originator, got.Originator)
}
if got.Recipients != scenario.ExpectedOutput.Recipients {
t.Errorf("expected recipients to be %s, got %s", scenario.ExpectedOutput.Recipients, got.Recipients)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+151 -23
View File
@@ -3,6 +3,7 @@ package ntfy
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -12,49 +13,141 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
DefaultURL = "https://ntfy.sh" DefaultURL = "https://ntfy.sh"
DefaultPriority = 3 DefaultPriority = 3
TokenPrefix = "tk_"
) )
var (
ErrInvalidToken = errors.New("invalid token")
ErrTopicNotSet = errors.New("topic not set")
ErrInvalidPriority = errors.New("priority must between 1 and 5 inclusively")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
Topic string `yaml:"topic"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
Email string `yaml:"email,omitempty"` // Defaults to ""
Click string `yaml:"click,omitempty"` // Defaults to ""
DisableFirebase bool `yaml:"disable-firebase,omitempty"` // Defaults to false
DisableCache bool `yaml:"disable-cache,omitempty"` // Defaults to false
}
func (cfg *Config) Validate() error {
if len(cfg.URL) == 0 {
cfg.URL = DefaultURL
}
if cfg.Priority == 0 {
cfg.Priority = DefaultPriority
}
if len(cfg.Token) > 0 && !strings.HasPrefix(cfg.Token, TokenPrefix) {
return ErrInvalidToken
}
if len(cfg.Topic) == 0 {
return ErrTopicNotSet
}
if cfg.Priority < 1 || cfg.Priority > 5 {
return ErrInvalidPriority
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.Topic) > 0 {
cfg.Topic = override.Topic
}
if len(override.URL) > 0 {
cfg.URL = override.URL
}
if override.Priority > 0 {
cfg.Priority = override.Priority
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.Email) > 0 {
cfg.Email = override.Email
}
if len(override.Click) > 0 {
cfg.Click = override.Click
}
if override.DisableFirebase {
cfg.DisableFirebase = true
}
if override.DisableCache {
cfg.DisableCache = true
}
}
// AlertProvider is the configuration necessary for sending an alert using Slack // AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct { type AlertProvider struct {
Topic string `yaml:"topic"` DefaultConfig Config `yaml:",inline"`
URL string `yaml:"url,omitempty"` // Defaults to DefaultURL
Priority int `yaml:"priority,omitempty"` // Defaults to DefaultPriority
Token string `yaml:"token,omitempty"` // Defaults to ""
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` 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"`
} }
// IsValid returns whether the provider's configuration is valid // Override is a case under which the default integration is overridden
func (provider *AlertProvider) IsValid() bool { type Override struct {
if len(provider.URL) == 0 { Group string `yaml:"group"`
provider.URL = DefaultURL 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 len(override.Group) == 0 {
return ErrDuplicateGroupOverride
}
if _, ok := registeredGroups[override.Group]; ok {
return ErrDuplicateGroupOverride
}
if len(override.Token) > 0 && !strings.HasPrefix(override.Token, TokenPrefix) {
return ErrDuplicateGroupOverride
}
if override.Priority < 0 || override.Priority >= 6 {
return ErrDuplicateGroupOverride
}
registeredGroups[override.Group] = true
}
} }
if provider.Priority == 0 {
provider.Priority = DefaultPriority return provider.DefaultConfig.Validate()
}
isTokenValid := true
if len(provider.Token) > 0 {
isTokenValid = strings.HasPrefix(provider.Token, "tk_")
}
return len(provider.URL) > 0 && len(provider.Topic) > 0 && provider.Priority > 0 && provider.Priority < 6 && isTokenValid
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.URL, buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
url := cfg.URL
request, err := http.NewRequest(http.MethodPost, url, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
if len(provider.Token) > 0 { if token := cfg.Token; len(token) > 0 {
request.Header.Set("Authorization", "Bearer "+provider.Token) request.Header.Set("Authorization", "Bearer "+token)
}
if cfg.DisableFirebase {
request.Header.Set("Firebase", "no")
}
if cfg.DisableCache {
request.Header.Set("Cache", "no")
} }
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
@@ -74,10 +167,12 @@ type Body struct {
Message string `json:"message"` Message string `json:"message"`
Tags []string `json:"tags"` Tags []string `json:"tags"`
Priority int `json:"priority"` Priority int `json:"priority"`
Email string `json:"email,omitempty"`
Click string `json:"click,omitempty"`
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, formattedConditionResults, tag string var message, formattedConditionResults, tag string
if resolved { if resolved {
tag = "white_check_mark" tag = "white_check_mark"
@@ -100,11 +195,13 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
} }
message += formattedConditionResults message += formattedConditionResults
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Topic: provider.Topic, Topic: cfg.Topic,
Title: "Gatus: " + ep.DisplayName(), Title: "Gatus: " + ep.DisplayName(),
Message: message, Message: message,
Tags: []string{tag}, Tags: []string{tag},
Priority: provider.Priority, Priority: cfg.Priority,
Email: cfg.Email,
Click: cfg.Click,
}) })
return body return body
} }
@@ -113,3 +210,34 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+313 -13
View File
@@ -2,13 +2,16 @@ package ntfy
import ( import (
"encoding/json" "encoding/json"
"io"
"net/http"
"net/http/httptest"
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
scenarios := []struct { scenarios := []struct {
name string name string
provider AlertProvider provider AlertProvider
@@ -16,49 +19,78 @@ func TestAlertDefaultProvider_IsValid(t *testing.T) {
}{ }{
{ {
name: "valid", name: "valid",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
expected: true, expected: true,
}, },
{ {
name: "no-url-should-use-default-value", name: "no-url-should-use-default-value",
provider: AlertProvider{Topic: "example", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1}},
expected: true, expected: true,
}, },
{ {
name: "valid-with-token", name: "valid-with-token",
provider: AlertProvider{Topic: "example", Priority: 1, Token: "tk_faketoken"}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "tk_faketoken"}},
expected: true, expected: true,
}, },
{ {
name: "invalid-token", name: "invalid-token",
provider: AlertProvider{Topic: "example", Priority: 1, Token: "xx_faketoken"}, provider: AlertProvider{DefaultConfig: Config{Topic: "example", Priority: 1, Token: "xx_faketoken"}},
expected: false, expected: false,
}, },
{ {
name: "invalid-topic", name: "invalid-topic",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "", Priority: 1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "", Priority: 1}},
expected: false, expected: false,
}, },
{ {
name: "invalid-priority-too-high", name: "invalid-priority-too-high",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 6}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 6}},
expected: false, expected: false,
}, },
{ {
name: "invalid-priority-too-low", name: "invalid-priority-too-low",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: -1}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: -1}},
expected: false, expected: false,
}, },
{ {
name: "no-priority-should-use-default-value", name: "no-priority-should-use-default-value",
provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example"}, provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example"}},
expected: true,
},
{
name: "invalid-override-token",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Token: "xx_faketoken"}}}},
expected: false,
},
{
name: "invalid-override-priority",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g", Config: Config{Priority: 8}}}},
expected: false,
},
{
name: "no-override-group-name",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{}}},
expected: false,
},
{
name: "duplicate-override-group-names",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g"}, {Group: "g"}}},
expected: false,
},
{
name: "valid-override",
provider: AlertProvider{DefaultConfig: Config{Topic: "example"}, Overrides: []Override{{Group: "g1", Config: Config{Priority: 4, Click: "https://example.com"}}, {Group: "g2", Config: Config{Topic: "Example", Token: "tk_faketoken"}}}},
expected: true, expected: true,
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.name, func(t *testing.T) { t.Run(scenario.name, func(t *testing.T) {
if scenario.provider.IsValid() != scenario.expected { err := scenario.provider.Validate()
t.Errorf("expected %t, got %t", scenario.expected, scenario.provider.IsValid()) if scenario.expected && err != nil {
t.Error("expected no error, got", err.Error())
}
if !scenario.expected && err == nil {
t.Error("expected error, got none")
} }
}) })
} }
@@ -76,22 +108,55 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 1}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1}`,
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{URL: "https://ntfy.sh", Topic: "example", Priority: 2}, Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`, ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2}`,
}, },
{
Name: "triggered-email",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
},
{
Name: "resolved-email",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 2, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\n🟢 [CONNECTED] == true\n🟢 [STATUS] == 200","tags":["white_check_mark"],"priority":2,"email":"test@example.com","click":"example.com"}`,
},
{
Name: "group-override",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: `{"topic":"group-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
},
{
Name: "alert-override",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 5, Email: "test@example.com", Click: "example.com"}, Overrides: []Override{{Group: "g", Config: Config{Topic: "group-topic", Priority: 4, Email: "override@test.com", Click: "test.com"}}}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3, ProviderOverride: map[string]any{"topic": "alert-topic"}},
Resolved: false,
ExpectedBody: `{"topic":"alert-topic","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":4,"email":"override@test.com","click":"test.com"}`,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
cfg, err := scenario.Provider.GetConfig("g", &scenario.Alert)
if err != nil {
t.Error("expected no error, got", err.Error())
}
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
cfg,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -112,3 +177,238 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_Send(t *testing.T) {
description := "description-1"
scenarios := []struct {
Name string
Provider AlertProvider
Alert alert.Alert
Resolved bool
Group string
ExpectedBody string
ExpectedHeaders map[string]string
}{
{
Name: "triggered",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
},
},
{
Name: "token",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer tk_mytoken",
},
},
{
Name: "no firebase",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
},
},
{
Name: "no cache",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableCache: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Cache": "no",
},
},
{
Name: "neither firebase & cache",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", DisableFirebase: true, DisableCache: true}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "",
ExpectedBody: `{"topic":"example","title":"Gatus: endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Firebase": "no",
"Cache": "no",
},
},
{
Name: "overrides",
Provider: AlertProvider{DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1, Email: "test@example.com", Click: "example.com", Token: "tk_mytoken"}, Overrides: []Override{Override{Group: "other-group", Config: Config{URL: "https://example.com", Token: "tk_othertoken"}}, Override{Group: "test-group", Config: Config{Token: "tk_test_token"}}}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
Group: "test-group",
ExpectedBody: `{"topic":"example","title":"Gatus: test-group/endpoint-name","message":"An alert has been triggered due to having failed 3 time(s) in a row with the following description: description-1\n🔴 [CONNECTED] == true\n🔴 [STATUS] == 200","tags":["rotating_light"],"priority":1,"email":"test@example.com","click":"example.com"}`,
ExpectedHeaders: map[string]string{
"Content-Type": "application/json",
"Authorization": "Bearer tk_test_token",
},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
// Start a local HTTP server
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
// Test request parameters
for header, value := range scenario.ExpectedHeaders {
if value != req.Header.Get(header) {
t.Errorf("expected: %s, got: %s", value, req.Header.Get(header))
}
}
body, _ := io.ReadAll(req.Body)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
// Send response to be tested
rw.Write([]byte(`OK`))
}))
// Close the server when test finishes
defer server.Close()
scenario.Provider.DefaultConfig.URL = server.URL
err := scenario.Provider.Send(
&endpoint.Endpoint{Name: "endpoint-name", Group: scenario.Group},
&scenario.Alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: scenario.Resolved},
{Condition: "[STATUS] == 200", Success: scenario.Resolved},
},
},
scenario.Resolved,
)
if err != nil {
t.Error("Encountered an error on Send: ", err)
}
})
}
}
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{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{URL: "https://group-example.com", Topic: "group-topic", Priority: 2},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"url": "http://alert-example.com", "topic": "alert-topic", "priority": 3}},
ExpectedOutput: Config{URL: "http://alert-example.com", Topic: "alert-topic", Priority: 3},
},
{
Name: "provider-with-partial-overrides",
Provider: AlertProvider{
DefaultConfig: Config{URL: "https://ntfy.sh", Topic: "example", Priority: 1},
Overrides: []Override{
{
Group: "group",
Config: Config{Topic: "group-topic"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"priority": 3}},
ExpectedOutput: Config{URL: "https://ntfy.sh", Topic: "group-topic", Priority: 3},
},
}
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.URL != scenario.ExpectedOutput.URL {
t.Errorf("expected url %s, got %s", scenario.ExpectedOutput.URL, got.URL)
}
if got.Topic != scenario.ExpectedOutput.Topic {
t.Errorf("expected topic %s, got %s", scenario.ExpectedOutput.Topic, got.Topic)
}
if got.Priority != scenario.ExpectedOutput.Priority {
t.Errorf("expected priority %d, got %d", scenario.ExpectedOutput.Priority, got.Priority)
}
// 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)
}
})
}
}
+97 -53
View File
@@ -3,6 +3,7 @@ package opsgenie
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -12,13 +13,18 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
restAPI = "https://api.opsgenie.com/v2/alerts" restAPI = "https://api.opsgenie.com/v2/alerts"
) )
type AlertProvider struct { var (
ErrAPIKeyNotSet = errors.New("api-key not set")
)
type Config struct {
// APIKey to use for // APIKey to use for
APIKey string `yaml:"api-key"` APIKey string `yaml:"api-key"`
@@ -46,26 +52,74 @@ type AlertProvider struct {
// //
// default: [] // default: []
Tags []string `yaml:"tags"` Tags []string `yaml:"tags"`
}
func (cfg *Config) Validate() error {
if len(cfg.APIKey) == 0 {
return ErrAPIKeyNotSet
}
if len(cfg.Source) == 0 {
cfg.Source = "gatus"
}
if len(cfg.EntityPrefix) == 0 {
cfg.EntityPrefix = "gatus-"
}
if len(cfg.AliasPrefix) == 0 {
cfg.AliasPrefix = "gatus-healthcheck-"
}
if len(cfg.Priority) == 0 {
cfg.Priority = "P1"
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.APIKey) > 0 {
cfg.APIKey = override.APIKey
}
if len(override.Priority) > 0 {
cfg.Priority = override.Priority
}
if len(override.Source) > 0 {
cfg.Source = override.Source
}
if len(override.EntityPrefix) > 0 {
cfg.EntityPrefix = override.EntityPrefix
}
if len(override.AliasPrefix) > 0 {
cfg.AliasPrefix = override.AliasPrefix
}
if len(override.Tags) > 0 {
cfg.Tags = override.Tags
}
}
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.APIKey) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://docs.opsgenie.com/docs/alert-api // Relevant: https://docs.opsgenie.com/docs/alert-api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
err := provider.createAlert(ep, alert, result, resolved) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
return err
}
err = provider.sendAlertRequest(cfg, ep, alert, result, resolved)
if err != nil { if err != nil {
return err return err
} }
if resolved { if resolved {
err = provider.closeAlert(ep, alert) err = provider.closeAlert(cfg, ep, alert)
if err != nil { if err != nil {
return err return err
} }
@@ -75,24 +129,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
// The alert has been resolved and there's no error, so we can clear the alert's ResolveKey // The alert has been resolved and there's no error, so we can clear the alert's ResolveKey
alert.ResolveKey = "" alert.ResolveKey = ""
} else { } else {
alert.ResolveKey = provider.alias(buildKey(ep)) alert.ResolveKey = cfg.AliasPrefix + buildKey(ep)
} }
} }
return nil return nil
} }
func (provider *AlertProvider) createAlert(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) sendAlertRequest(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
payload := provider.buildCreateRequestBody(ep, alert, result, resolved) payload := provider.buildCreateRequestBody(cfg, ep, alert, result, resolved)
return provider.sendRequest(restAPI, http.MethodPost, payload) return provider.sendRequest(cfg, restAPI, http.MethodPost, payload)
} }
func (provider *AlertProvider) closeAlert(ep *endpoint.Endpoint, alert *alert.Alert) error { func (provider *AlertProvider) closeAlert(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert) error {
payload := provider.buildCloseRequestBody(ep, alert) payload := provider.buildCloseRequestBody(ep, alert)
url := restAPI + "/" + provider.alias(buildKey(ep)) + "/close?identifierType=alias" url := restAPI + "/" + cfg.AliasPrefix + buildKey(ep) + "/close?identifierType=alias"
return provider.sendRequest(url, http.MethodPost, payload) return provider.sendRequest(cfg, url, http.MethodPost, payload)
} }
func (provider *AlertProvider) sendRequest(url, method string, payload interface{}) error { func (provider *AlertProvider) sendRequest(cfg *Config, url, method string, payload interface{}) error {
body, err := json.Marshal(payload) body, err := json.Marshal(payload)
if err != nil { if err != nil {
return fmt.Errorf("error build alert with payload %v: %w", payload, err) return fmt.Errorf("error build alert with payload %v: %w", payload, err)
@@ -102,7 +156,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
request.Header.Set("Authorization", "GenieKey "+provider.APIKey) request.Header.Set("Authorization", "GenieKey "+cfg.APIKey)
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -115,7 +169,7 @@ func (provider *AlertProvider) sendRequest(url, method string, payload interface
return nil return nil
} }
func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest { func (provider *AlertProvider) buildCreateRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) alertCreateRequest {
var message, description string var message, description string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.Name, alert.GetDescription())
@@ -158,11 +212,11 @@ func (provider *AlertProvider) buildCreateRequestBody(ep *endpoint.Endpoint, ale
return alertCreateRequest{ return alertCreateRequest{
Message: message, Message: message,
Description: description, Description: description,
Source: provider.source(), Source: cfg.Source,
Priority: provider.priority(), Priority: cfg.Priority,
Alias: provider.alias(key), Alias: cfg.AliasPrefix + key,
Entity: provider.entity(key), Entity: cfg.EntityPrefix + key,
Tags: provider.Tags, Tags: cfg.Tags,
Details: details, Details: details,
} }
} }
@@ -174,43 +228,33 @@ func (provider *AlertProvider) buildCloseRequestBody(ep *endpoint.Endpoint, aler
} }
} }
func (provider *AlertProvider) source() string {
source := provider.Source
if source == "" {
return "gatus"
}
return source
}
func (provider *AlertProvider) alias(key string) string {
alias := provider.AliasPrefix
if alias == "" {
alias = "gatus-healthcheck-"
}
return alias + key
}
func (provider *AlertProvider) entity(key string) string {
alias := provider.EntityPrefix
if alias == "" {
alias = "gatus-"
}
return alias + key
}
func (provider *AlertProvider) priority() string {
priority := provider.Priority
if priority == "" {
return "P1"
}
return priority
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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
}
func buildKey(ep *endpoint.Endpoint) string { func buildKey(ep *endpoint.Endpoint) string {
name := toKebabCase(ep.Name) name := toKebabCase(ep.Name)
if ep.Group == "" { if ep.Group == "" {
+64 -22
View File
@@ -11,13 +11,13 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{APIKey: ""} invalidProvider := AlertProvider{DefaultConfig: Config{APIKey: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{APIKey: "00000000-0000-0000-0000-000000000000"} validProvider := AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -35,7 +35,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1}, Alert: alert.Alert{Description: &description, SuccessThreshold: 1, FailureThreshold: 1},
Resolved: false, Resolved: false,
ExpectedError: false, ExpectedError: false,
@@ -45,7 +45,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedError: true, ExpectedError: true,
@@ -55,7 +55,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: false, ExpectedError: false,
@@ -65,7 +65,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &description, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedError: true, ExpectedError: true,
@@ -74,7 +74,6 @@ func TestAlertProvider_Send(t *testing.T) {
}), }),
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper}) client.InjectHTTPClient(&http.Client{Transport: scenario.MockRoundTripper})
@@ -113,7 +112,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}{ }{
{ {
Name: "missing all params (unresolved)", Name: "missing all params (unresolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{}, Result: &endpoint.Result{},
@@ -131,7 +130,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}, },
{ {
Name: "missing all params (resolved)", Name: "missing all params (resolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{}, Alert: &alert.Alert{},
Endpoint: &endpoint.Endpoint{}, Endpoint: &endpoint.Endpoint{},
Result: &endpoint.Result{}, Result: &endpoint.Result{},
@@ -149,7 +148,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
}, },
{ {
Name: "with default options (unresolved)", Name: "with default options (unresolved)",
Provider: &AlertProvider{}, Provider: &AlertProvider{DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"}},
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
FailureThreshold: 3, FailureThreshold: 3,
@@ -184,11 +183,13 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{ {
Name: "with custom options (resolved)", Name: "with custom options (resolved)",
Provider: &AlertProvider{ Provider: &AlertProvider{
Priority: "P5", DefaultConfig: Config{
EntityPrefix: "oompa-", Priority: "P5",
AliasPrefix: "loompa-", EntityPrefix: "oompa-",
Source: "gatus-hc", AliasPrefix: "loompa-",
Tags: []string{"do-ba-dee-doo"}, Source: "gatus-hc",
Tags: []string{"do-ba-dee-doo"},
},
}, },
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
@@ -220,7 +221,7 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
{ {
Name: "with default options and details (unresolved)", Name: "with default options and details (unresolved)",
Provider: &AlertProvider{ Provider: &AlertProvider{
Tags: []string{"foo"}, DefaultConfig: Config{Tags: []string{"foo"}, APIKey: "00000000-0000-0000-0000-000000000000"},
}, },
Alert: &alert.Alert{ Alert: &alert.Alert{
Description: &description, Description: &description,
@@ -265,8 +266,9 @@ func TestAlertProvider_buildCreateRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
actual := scenario actual := scenario
t.Run(actual.Name, func(t *testing.T) { t.Run(actual.Name, func(t *testing.T) {
if got := actual.Provider.buildCreateRequestBody(actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) { _ = scenario.Provider.Validate()
t.Errorf("buildCreateRequestBody() = %v, want %v", got, actual.want) if got := actual.Provider.buildCreateRequestBody(&scenario.Provider.DefaultConfig, actual.Endpoint, actual.Alert, actual.Result, actual.Resolved); !reflect.DeepEqual(got, actual.want) {
t.Errorf("got:\n%v\nwant:\n%v", got, actual.want)
} }
}) })
} }
@@ -307,7 +309,6 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
}, },
}, },
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
actual := scenario actual := scenario
t.Run(actual.Name, func(t *testing.T) { t.Run(actual.Name, func(t *testing.T) {
@@ -317,3 +318,44 @@ func TestAlertProvider_buildCloseRequestBody(t *testing.T) {
}) })
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
{
Name: "provider-with-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{APIKey: "00000000-0000-0000-0000-000000000000"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"api-key": "00000000-0000-0000-0000-000000000001"}},
ExpectedOutput: Config{APIKey: "00000000-0000-0000-0000-000000000001"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &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)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides("", &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+73 -27
View File
@@ -3,23 +3,47 @@ package pagerduty
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"log"
"net/http" "net/http"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/logr"
"gopkg.in/yaml.v3"
) )
const ( const (
restAPIURL = "https://events.pagerduty.com/v2/enqueue" restAPIURL = "https://events.pagerduty.com/v2/enqueue"
) )
var (
ErrIntegrationKeyNotSet = errors.New("integration-key must have exactly 32 characters")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
IntegrationKey string `yaml:"integration-key"`
}
func (cfg *Config) Validate() error {
if len(cfg.IntegrationKey) != 32 {
return ErrIntegrationKeyNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.IntegrationKey) > 0 {
cfg.IntegrationKey = override.IntegrationKey
}
}
// AlertProvider is the configuration necessary for sending an alert using PagerDuty // AlertProvider is the configuration necessary for sending an alert using PagerDuty
type AlertProvider struct { type AlertProvider struct {
IntegrationKey string `yaml:"integration-key"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
@@ -30,30 +54,34 @@ type AlertProvider struct {
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
IntegrationKey string `yaml:"integration-key"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.IntegrationKey) != 32 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
// Either the default integration key has the right length, or there are overrides who are properly configured. // Either the default integration key has the right length, or there are overrides who are properly configured.
return len(provider.IntegrationKey) == 32 || len(provider.Overrides) != 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
// //
// Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/ // Relevant: https://developer.pagerduty.com/docs/events-api-v2/trigger-events/
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
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, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@@ -74,11 +102,10 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
alert.ResolveKey = "" alert.ResolveKey = ""
} else { } else {
// We need to retrieve the resolve key from the response // We need to retrieve the resolve key from the response
body, err := io.ReadAll(response.Body)
var payload pagerDutyResponsePayload var payload pagerDutyResponsePayload
if err = json.Unmarshal(body, &payload); err != nil { if err = json.NewDecoder(response.Body).Decode(&payload); err != nil {
// Silently fail. We don't want to create tons of alerts just because we failed to parse the body. // Silently fail. We don't want to create tons of alerts just because we failed to parse the body.
log.Printf("[pagerduty.Send] Ran into error unmarshaling pagerduty response: %s", err.Error()) logr.Errorf("[pagerduty.Send] Ran into error decoding pagerduty response: %s", err.Error())
} else { } else {
alert.ResolveKey = payload.DedupKey alert.ResolveKey = payload.DedupKey
} }
@@ -101,7 +128,7 @@ type Payload struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, eventAction, resolveKey string var message, eventAction, resolveKey string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
@@ -113,7 +140,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
resolveKey = "" resolveKey = ""
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
RoutingKey: provider.getIntegrationKeyForGroup(ep.Group), RoutingKey: cfg.IntegrationKey,
DedupKey: resolveKey, DedupKey: resolveKey,
EventAction: eventAction, EventAction: eventAction,
Payload: Payload{ Payload: Payload{
@@ -125,23 +152,42 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return body return body
} }
// getIntegrationKeyForGroup returns the appropriate pagerduty integration key for a given group
func (provider *AlertProvider) getIntegrationKeyForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.IntegrationKey
}
}
}
return provider.IntegrationKey
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
type pagerDutyResponsePayload struct { type pagerDutyResponsePayload struct {
Status string `json:"status"` Status string `json:"status"`
Message string `json:"message"` Message string `json:"message"`
+113 -94
View File
@@ -11,50 +11,41 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{IntegrationKey: ""} invalidProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{IntegrationKey: "00000000000000000000000000000000"} validProvider := AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{ Overrides: []Override{
{ {
IntegrationKey: "00000000000000000000000000000000", Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideIntegrationKey := AlertProvider{
Overrides: []Override{
{
IntegrationKey: "",
Group: "group",
},
},
}
if providerWithInvalidOverrideIntegrationKey.IsValid() {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{ Overrides: []Override{
{ {
IntegrationKey: "00000000000000000000000000000000", Config: Config{IntegrationKey: "00000000000000000000000000000002"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid, got error:", err.Error())
} }
} }
@@ -72,7 +63,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -82,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -92,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -102,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -146,14 +137,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description}, Alert: alert.Alert{Description: &description},
Resolved: false, Resolved: false,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}", ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"\",\"event_action\":\"trigger\",\"payload\":{\"summary\":\"TRIGGERED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{IntegrationKey: "00000000000000000000000000000000"}, Provider: AlertProvider{DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000000"}},
Alert: alert.Alert{Description: &description, ResolveKey: "key"}, Alert: alert.Alert{Description: &description, ResolveKey: "key"},
Resolved: true, Resolved: true,
ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}", ExpectedBody: "{\"routing_key\":\"00000000000000000000000000000000\",\"dedup_key\":\"key\",\"event_action\":\"resolve\",\"payload\":{\"summary\":\"RESOLVED: endpoint-name - test\",\"source\":\"Gatus\",\"severity\":\"critical\"}}",
@@ -161,7 +152,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody(&endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved) body := scenario.Provider.buildRequestBody(&scenario.Provider.DefaultConfig, &endpoint.Endpoint{Name: "endpoint-name"}, &scenario.Alert, &endpoint.Result{}, scenario.Resolved)
if string(body) != scenario.ExpectedBody { if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body) t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
} }
@@ -173,69 +164,6 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
func TestAlertProvider_getIntegrationKeyForGroup(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
ExpectedOutput string
}{
{
Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: nil,
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "",
ExpectedOutput: "00000000000000000000000000000001",
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
IntegrationKey: "00000000000000000000000000000001",
Overrides: []Override{
{
Group: "group",
IntegrationKey: "00000000000000000000000000000002",
},
},
},
InputGroup: "group",
ExpectedOutput: "00000000000000000000000000000002",
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if output := scenario.Provider.getIntegrationKeyForGroup(scenario.InputGroup); output != scenario.ExpectedOutput {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput, output)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) { func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil { if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("expected default alert to be not nil") t.Error("expected default alert to be not nil")
@@ -244,3 +172,94 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be 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{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000001"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000002"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{IntegrationKey: "00000000000000000000000000000001"},
Overrides: []Override{
{
Group: "group",
Config: Config{IntegrationKey: "00000000000000000000000000000002"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"integration-key": "00000000000000000000000000000003"}},
ExpectedOutput: Config{IntegrationKey: "00000000000000000000000000000003"},
},
}
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.IntegrationKey != scenario.ExpectedOutput.IntegrationKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.IntegrationKey, got.IntegrationKey)
}
// 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)
}
})
}
}
+56 -8
View File
@@ -10,6 +10,10 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/github" "github.com/TwiN/gatus/v5/alerting/provider/github"
"github.com/TwiN/gatus/v5/alerting/provider/gitlab" "github.com/TwiN/gatus/v5/alerting/provider/gitlab"
"github.com/TwiN/gatus/v5/alerting/provider/googlechat" "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/incidentio"
"github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace" "github.com/TwiN/gatus/v5/alerting/provider/jetbrainsspace"
"github.com/TwiN/gatus/v5/alerting/provider/matrix" "github.com/TwiN/gatus/v5/alerting/provider/matrix"
"github.com/TwiN/gatus/v5/alerting/provider/mattermost" "github.com/TwiN/gatus/v5/alerting/provider/mattermost"
@@ -20,25 +24,35 @@ import (
"github.com/TwiN/gatus/v5/alerting/provider/pushover" "github.com/TwiN/gatus/v5/alerting/provider/pushover"
"github.com/TwiN/gatus/v5/alerting/provider/slack" "github.com/TwiN/gatus/v5/alerting/provider/slack"
"github.com/TwiN/gatus/v5/alerting/provider/teams" "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/telegram"
"github.com/TwiN/gatus/v5/alerting/provider/twilio" "github.com/TwiN/gatus/v5/alerting/provider/twilio"
"github.com/TwiN/gatus/v5/alerting/provider/zulip"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
) )
// AlertProvider is the interface that each provider should implement // AlertProvider is the interface that each provider should implement
type AlertProvider interface { type AlertProvider interface {
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
IsValid() bool Validate() error
// Send an alert using the provider
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
GetDefaultAlert() *alert.Alert GetDefaultAlert() *alert.Alert
// Send an alert using the provider // ValidateOverrides validates the alert's provider override and, if present, the group override
Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error ValidateOverrides(group string, alert *alert.Alert) error
} }
// ParseWithDefaultAlert parses an Endpoint alert by using the provider's default alert as a baseline type Config[T any] interface {
func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) { Validate() error
Merge(override *T)
}
// MergeProviderDefaultAlertIntoEndpointAlert parses an Endpoint alert by using the provider's default alert as a baseline
func MergeProviderDefaultAlertIntoEndpointAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
if providerDefaultAlert == nil || endpointAlert == nil { if providerDefaultAlert == nil || endpointAlert == nil {
return return
} }
@@ -60,15 +74,19 @@ func ParseWithDefaultAlert(providerDefaultAlert, endpointAlert *alert.Alert) {
} }
var ( var (
// Validate interface implementation on compile // Validate provider interface implementation on compile
_ AlertProvider = (*awsses.AlertProvider)(nil) _ AlertProvider = (*awsses.AlertProvider)(nil)
_ AlertProvider = (*custom.AlertProvider)(nil) _ AlertProvider = (*custom.AlertProvider)(nil)
_ AlertProvider = (*discord.AlertProvider)(nil) _ AlertProvider = (*discord.AlertProvider)(nil)
_ AlertProvider = (*email.AlertProvider)(nil) _ AlertProvider = (*email.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*github.AlertProvider)(nil) _ AlertProvider = (*github.AlertProvider)(nil)
_ AlertProvider = (*gitlab.AlertProvider)(nil) _ AlertProvider = (*gitlab.AlertProvider)(nil)
_ AlertProvider = (*gitea.AlertProvider)(nil)
_ AlertProvider = (*googlechat.AlertProvider)(nil) _ AlertProvider = (*googlechat.AlertProvider)(nil)
_ AlertProvider = (*gotify.AlertProvider)(nil)
_ AlertProvider = (*homeassistant.AlertProvider)(nil)
_ AlertProvider = (*ilert.AlertProvider)(nil)
_ AlertProvider = (*incidentio.AlertProvider)(nil)
_ AlertProvider = (*jetbrainsspace.AlertProvider)(nil) _ AlertProvider = (*jetbrainsspace.AlertProvider)(nil)
_ AlertProvider = (*matrix.AlertProvider)(nil) _ AlertProvider = (*matrix.AlertProvider)(nil)
_ AlertProvider = (*mattermost.AlertProvider)(nil) _ AlertProvider = (*mattermost.AlertProvider)(nil)
@@ -79,6 +97,36 @@ var (
_ AlertProvider = (*pushover.AlertProvider)(nil) _ AlertProvider = (*pushover.AlertProvider)(nil)
_ AlertProvider = (*slack.AlertProvider)(nil) _ AlertProvider = (*slack.AlertProvider)(nil)
_ AlertProvider = (*teams.AlertProvider)(nil) _ AlertProvider = (*teams.AlertProvider)(nil)
_ AlertProvider = (*teamsworkflows.AlertProvider)(nil)
_ AlertProvider = (*telegram.AlertProvider)(nil) _ AlertProvider = (*telegram.AlertProvider)(nil)
_ AlertProvider = (*twilio.AlertProvider)(nil) _ AlertProvider = (*twilio.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[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[homeassistant.Config] = (*homeassistant.Config)(nil)
_ Config[ilert.Config] = (*ilert.Config)(nil)
_ Config[incidentio.Config] = (*incidentio.Config)(nil)
_ Config[jetbrainsspace.Config] = (*jetbrainsspace.Config)(nil)
_ Config[matrix.Config] = (*matrix.Config)(nil)
_ Config[mattermost.Config] = (*mattermost.Config)(nil)
_ Config[messagebird.Config] = (*messagebird.Config)(nil)
_ Config[ntfy.Config] = (*ntfy.Config)(nil)
_ Config[opsgenie.Config] = (*opsgenie.Config)(nil)
_ Config[pagerduty.Config] = (*pagerduty.Config)(nil)
_ Config[pushover.Config] = (*pushover.Config)(nil)
_ Config[slack.Config] = (*slack.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[zulip.Config] = (*zulip.Config)(nil)
) )
+1 -1
View File
@@ -126,7 +126,7 @@ func TestParseWithDefaultAlert(t *testing.T) {
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
ParseWithDefaultAlert(scenario.DefaultAlert, scenario.EndpointAlert) MergeProviderDefaultAlertIntoEndpointAlert(scenario.DefaultAlert, scenario.EndpointAlert)
if scenario.ExpectedOutputAlert == nil { if scenario.ExpectedOutputAlert == nil {
if scenario.EndpointAlert != nil { if scenario.EndpointAlert != nil {
t.Fail() t.Fail()
+143 -27
View File
@@ -3,6 +3,7 @@ package pushover
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,6 +11,7 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const ( const (
@@ -17,8 +19,14 @@ const (
defaultPriority = 0 defaultPriority = 0
) )
// AlertProvider is the configuration necessary for sending an alert using Pushover var (
type AlertProvider struct { ErrInvalidApplicationToken = errors.New("application-token must be 30 characters long")
ErrInvalidUserKey = errors.New("user-key must be 30 characters long")
ErrInvalidPriority = errors.New("priority and resolved-priority must be between -2 and 2")
ErrInvalidDevice = errors.New("device name must have 25 characters or less")
)
type Config struct {
// Key used to authenticate the application sending // Key used to authenticate the application sending
// See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build // See "Your Applications" on the dashboard, or add a new one: https://pushover.net/apps/build
ApplicationToken string `yaml:"application-token"` ApplicationToken string `yaml:"application-token"`
@@ -26,34 +34,102 @@ type AlertProvider struct {
// Key of the user or group the messages should be sent to // Key of the user or group the messages should be sent to
UserKey string `yaml:"user-key"` UserKey string `yaml:"user-key"`
// The title of your message, likely the application name // The title of your message
// default: the name of your application in Pushover // default: "Gatus: <endpoint>""
Title string `yaml:"title,omitempty"` Title string `yaml:"title,omitempty"`
// Priority of all messages, ranging from -2 (very low) to 2 (Emergency) // Priority of all messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0 // default: 0
Priority int `yaml:"priority,omitempty"` Priority int `yaml:"priority,omitempty"`
// Priority of resolved messages, ranging from -2 (very low) to 2 (Emergency)
// default: 0
ResolvedPriority int `yaml:"resolved-priority,omitempty"`
// Sound of the messages (see: https://pushover.net/api#sounds) // Sound of the messages (see: https://pushover.net/api#sounds)
// default: "" (pushover) // default: "" (pushover)
Sound string `yaml:"sound,omitempty"` Sound string `yaml:"sound,omitempty"`
// TTL of your message (https://pushover.net/api#ttl)
// If priority is 2 then this parameter is ignored
// default: 0
TTL int `yaml:"ttl,omitempty"`
// Device to send the message to (see: https://pushover.net/api#devices)
// default: "" (all devices)
Device string `yaml:"device,omitempty"`
}
func (cfg *Config) Validate() error {
if cfg.Priority == 0 {
cfg.Priority = defaultPriority
}
if cfg.ResolvedPriority == 0 {
cfg.ResolvedPriority = defaultPriority
}
if len(cfg.ApplicationToken) != 30 {
return ErrInvalidApplicationToken
}
if len(cfg.UserKey) != 30 {
return ErrInvalidUserKey
}
if cfg.Priority < -2 || cfg.Priority > 2 || cfg.ResolvedPriority < -2 || cfg.ResolvedPriority > 2 {
return ErrInvalidPriority
}
if len(cfg.Device) > 25 {
return ErrInvalidDevice
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.ApplicationToken) > 0 {
cfg.ApplicationToken = override.ApplicationToken
}
if len(override.UserKey) > 0 {
cfg.UserKey = override.UserKey
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
if override.Priority != 0 {
cfg.Priority = override.Priority
}
if override.ResolvedPriority != 0 {
cfg.ResolvedPriority = override.ResolvedPriority
}
if len(override.Sound) > 0 {
cfg.Sound = override.Sound
}
if override.TTL > 0 {
cfg.TTL = override.TTL
}
if len(override.Device) > 0 {
cfg.Device = override.Device
}
}
// AlertProvider is the configuration necessary for sending an alert using Pushover
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.Priority == 0 { return provider.DefaultConfig.Validate()
provider.Priority = defaultPriority
}
return len(provider.ApplicationToken) == 30 && len(provider.UserKey) == 30 && provider.Priority >= -2 && provider.Priority <= 2
} }
// Send an alert using the provider // Send an alert using the provider
// Reference doc for pushover: https://pushover.net/api // Reference doc for pushover: https://pushover.net/api
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
if err != nil {
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, restAPIURL, buffer)
if err != nil { if err != nil {
return err return err
@@ -77,36 +153,76 @@ type Body struct {
Title string `json:"title,omitempty"` Title string `json:"title,omitempty"`
Message string `json:"message"` Message string `json:"message"`
Priority int `json:"priority"` Priority int `json:"priority"`
Html int `json:"html"`
Sound string `json:"sound,omitempty"` Sound string `json:"sound,omitempty"`
TTL int `json:"ttl,omitempty"`
Device string `json:"device,omitempty"`
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message, formattedConditionResults string
priority := cfg.Priority
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) priority = cfg.ResolvedPriority
message = fmt.Sprintf("An alert for <b>%s</b> has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) message = fmt.Sprintf("An alert for <b>%s</b> has been triggered due to having failed %d time(s) in a row", ep.DisplayName(), alert.FailureThreshold)
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = "✅"
} else {
prefix = "❌"
}
formattedConditionResults += fmt.Sprintf("\n%s - %s", prefix, conditionResult.Condition)
}
if len(alert.GetDescription()) > 0 {
message += " with the following description: " + alert.GetDescription()
}
message += formattedConditionResults
title := "Gatus: " + ep.DisplayName()
if cfg.Title != "" {
title = cfg.Title
} }
body, _ := json.Marshal(Body{ body, _ := json.Marshal(Body{
Token: provider.ApplicationToken, Token: cfg.ApplicationToken,
User: provider.UserKey, User: cfg.UserKey,
Title: provider.Title, Title: title,
Message: message, Message: message,
Priority: provider.priority(), Priority: priority,
Sound: provider.Sound, Html: 1,
Sound: cfg.Sound,
TTL: cfg.TTL,
Device: cfg.Device,
}) })
return body return body
} }
func (provider *AlertProvider) priority() int {
if provider.Priority == 0 {
return defaultPriority
}
return provider.Priority
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 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
}
+124 -39
View File
@@ -12,30 +12,38 @@ import (
) )
func TestPushoverAlertProvider_IsValid(t *testing.T) { func TestPushoverAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} t.Run("empty-invalid-provider", func(t *testing.T) {
if invalidProvider.IsValid() { invalidProvider := AlertProvider{}
t.Error("provider shouldn't have been valid") if err := invalidProvider.Validate(); err == nil {
} t.Error("provider shouldn't have been valid")
validProvider := AlertProvider{ }
ApplicationToken: "aTokenWithLengthOf30characters", })
UserKey: "aTokenWithLengthOf30characters", t.Run("valid-provider", func(t *testing.T) {
Title: "Gatus Notification", validProvider := AlertProvider{
Priority: 1, DefaultConfig: Config{
} ApplicationToken: "aTokenWithLengthOf30characters",
if !validProvider.IsValid() { UserKey: "aTokenWithLengthOf30characters",
t.Error("provider should've been valid") Title: "Gatus Notification",
} Priority: 1,
} ResolvedPriority: 1,
},
func TestPushoverAlertProvider_IsInvalid(t *testing.T) { }
invalidProvider := AlertProvider{ if err := validProvider.Validate(); err != nil {
ApplicationToken: "aTokenWithLengthOfMoreThan30characters", t.Error("provider should've been valid")
UserKey: "aTokenWithLengthOfMoreThan30characters", }
Priority: 5, })
} t.Run("invalid-provider", func(t *testing.T) {
if invalidProvider.IsValid() { invalidProvider := AlertProvider{
t.Error("provider should've been invalid") DefaultConfig: Config{
} ApplicationToken: "aTokenWithLengthOfMoreThan30characters",
UserKey: "aTokenWithLengthOfMoreThan30characters",
Priority: 5,
},
}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider should've been invalid")
}
})
} }
func TestAlertProvider_Send(t *testing.T) { func TestAlertProvider_Send(t *testing.T) {
@@ -52,7 +60,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -62,7 +70,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -72,7 +80,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -82,7 +90,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -119,37 +127,67 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
secondDescription := "description-2" secondDescription := "description-2"
scenarios := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
Alert alert.Alert Alert alert.Alert
Resolved bool Resolved bool
ExpectedBody string ResolvedPriority bool
ExpectedBody string
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"message\":\"TRIGGERED: endpoint-name - description-1\",\"priority\":0}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been triggered due to having failed 3 time(s) in a row with the following description: description-1\\n❌ - [CONNECTED] == true\\n❌ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
},
{
Name: "triggered-customtitle",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters1", UserKey: "TokenWithLengthOf30Characters4", Title: "Gatus Notifications"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters1\",\"user\":\"TokenWithLengthOf30Characters4\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been triggered due to having failed 3 time(s) in a row with the following description: description-1\\n❌ - [CONNECTED] == true\\n❌ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Priority: 2, ResolvedPriority: 2}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus: endpoint-name\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1}",
},
{
Name: "resolved-priority",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 0}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":0,\"html\":1}",
}, },
{ {
Name: "with-sound", Name: "with-sound",
Provider: AlertProvider{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, Sound: "falling"}, Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, Sound: "falling"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"RESOLVED: endpoint-name - description-2\",\"priority\":2,\"sound\":\"falling\"}", ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"sound\":\"falling\"}",
}, },
{
Name: "with-ttl",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600}",
},
{
Name: "with-device",
Provider: AlertProvider{DefaultConfig: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters5", Title: "Gatus Notifications", Priority: 2, ResolvedPriority: 2, TTL: 3600, Device: "iphone15pro",}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"token\":\"TokenWithLengthOf30Characters2\",\"user\":\"TokenWithLengthOf30Characters5\",\"title\":\"Gatus Notifications\",\"message\":\"An alert for \\u003cb\\u003eendpoint-name\\u003c/b\\u003e has been resolved after passing successfully 5 time(s) in a row with the following description: description-2\\n✅ - [CONNECTED] == true\\n✅ - [STATUS] == 200\",\"priority\":2,\"html\":1,\"ttl\":3600,\"device\":\"iphone15pro\"}",
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -179,3 +217,50 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be 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{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{ApplicationToken: "aTokenWithLengthOf30characters", UserKey: "aTokenWithLengthOf30characters"},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"application-token": "TokenWithLengthOf30Characters2", "user-key": "TokenWithLengthOf30Characters3"}},
ExpectedOutput: Config{ApplicationToken: "TokenWithLengthOf30Characters2", UserKey: "TokenWithLengthOf30Characters3"},
},
}
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.ApplicationToken != scenario.ExpectedOutput.ApplicationToken {
t.Errorf("expected application token to be %s, got %s", scenario.ExpectedOutput.ApplicationToken, got.ApplicationToken)
}
if got.UserKey != scenario.ExpectedOutput.UserKey {
t.Errorf("expected user key to be %s, got %s", scenario.ExpectedOutput.UserKey, got.UserKey)
}
// 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)
}
})
}
}
+70 -21
View File
@@ -3,6 +3,7 @@ package slack
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,41 +11,70 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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"` // Slack 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 Slack // AlertProvider is the configuration necessary for sending an alert using Slack
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` // Slack webhook URL DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
} }
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { 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
}
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
@@ -126,19 +156,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON return bodyAsJSON
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+69 -41
View File
@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "https://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "https://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -227,64 +227,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getWebhookURLForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// 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)
} }
}) })
} }
+82 -28
View File
@@ -3,6 +3,7 @@ package teams
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,51 +11,85 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "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"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
// 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.WebhookURL) == 0 {
return ErrWebhookURLNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.WebhookURL) > 0 {
cfg.WebhookURL = override.WebhookURL
}
if len(override.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Teams // AlertProvider is the configuration necessary for sending an alert using Teams
type AlertProvider struct { type AlertProvider struct {
WebhookURL string `yaml:"webhook-url"` DefaultConfig Config `yaml:",inline"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Override that may be prioritized over the default configuration // Overrides is a list of Override that may be prioritized over the default configuration
Overrides []Override `yaml:"overrides,omitempty"` Overrides []Override `yaml:"overrides,omitempty"`
// Title is the title of the message that will be sent
Title string `yaml:"title,omitempty"`
} }
// Override is a case under which the default integration is overridden // Override is a case under which the default integration is overridden
type Override struct { type Override struct {
Group string `yaml:"group"` Group string `yaml:"group"`
WebhookURL string `yaml:"webhook-url"` Config `yaml:",inline"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
registeredGroups := make(map[string]bool) registeredGroups := make(map[string]bool)
if provider.Overrides != nil { if provider.Overrides != nil {
for _, override := range provider.Overrides { for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" || len(override.WebhookURL) == 0 { if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
return false return ErrDuplicateGroupOverride
} }
registeredGroups[override.Group] = true registeredGroups[override.Group] = true
} }
} }
return len(provider.WebhookURL) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, provider.getWebhookURLForGroup(ep.Group), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, cfg.WebhookURL, buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -81,7 +116,7 @@ type Section struct {
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message, color string var message, color string
if resolved { 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) message = fmt.Sprintf("An alert for *%s* has been resolved after passing successfully %d time(s) in a row", ep.DisplayName(), alert.SuccessThreshold)
@@ -108,7 +143,7 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
Type: "MessageCard", Type: "MessageCard",
Context: "http://schema.org/extensions", Context: "http://schema.org/extensions",
ThemeColor: color, ThemeColor: color,
Title: provider.Title, Title: cfg.Title,
Text: message + description, Text: message + description,
} }
if len(body.Title) == 0 { if len(body.Title) == 0 {
@@ -124,19 +159,38 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
return bodyAsJSON return bodyAsJSON
} }
// getWebhookURLForGroup returns the appropriate Webhook URL integration to for a given group
func (provider *AlertProvider) getWebhookURLForGroup(group string) string {
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
return override.WebhookURL
}
}
}
return provider.WebhookURL
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+70 -41
View File
@@ -11,50 +11,50 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
invalidProvider := AlertProvider{WebhookURL: ""} invalidProvider := AlertProvider{DefaultConfig: Config{WebhookURL: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{WebhookURL: "http://example.com"} validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
func TestAlertProvider_IsValidWithOverride(t *testing.T) { func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{ providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "", Group: "",
}, },
}, },
} }
if providerWithInvalidOverrideGroup.IsValid() { if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid") t.Error("provider Group shouldn't have been valid")
} }
providerWithInvalidOverrideTo := AlertProvider{ providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "", Config: Config{WebhookURL: ""},
Group: "group", Group: "group",
}, },
}, },
} }
if providerWithInvalidOverrideTo.IsValid() { if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid") t.Error("provider integration key shouldn't have been valid")
} }
providerWithValidOverride := AlertProvider{ providerWithValidOverride := AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
WebhookURL: "http://example.com", Config: Config{WebhookURL: "http://example.com"},
Group: "group", Group: "group",
}, },
}, },
} }
if !providerWithValidOverride.IsValid() { if err := providerWithValidOverride.Validate(); err != nil {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
} }
@@ -73,7 +73,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -83,7 +83,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -93,7 +93,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -103,7 +103,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -180,6 +180,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
@@ -205,64 +206,92 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
} }
} }
func TestAlertProvider_getWebhookURLForGroup(t *testing.T) { func TestAlertProvider_GetConfig(t *testing.T) {
tests := []struct { scenarios := []struct {
Name string Name string
Provider AlertProvider Provider AlertProvider
InputGroup string InputGroup string
ExpectedOutput string InputAlert alert.Alert
ExpectedOutput Config
}{ }{
{ {
Name: "provider-no-override-specify-no-group-should-default", Name: "provider-no-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-no-override-specify-group-should-default", Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil, Overrides: nil,
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-no-group-should-default", Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://example01.com"},
}, },
}, },
}, },
InputGroup: "", InputGroup: "",
ExpectedOutput: "http://example.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
}, },
{ {
Name: "provider-with-override-specify-group-should-override", Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{ Provider: AlertProvider{
WebhookURL: "http://example.com", DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{ Overrides: []Override{
{ {
Group: "group", Group: "group",
WebhookURL: "http://example01.com", Config: Config{WebhookURL: "http://group-example.com"},
}, },
}, },
}, },
InputGroup: "group", InputGroup: "group",
ExpectedOutput: "http://example01.com", InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://alert-example.com"},
}, },
} }
for _, tt := range tests { for _, scenario := range scenarios {
t.Run(tt.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
if got := tt.Provider.getWebhookURLForGroup(tt.InputGroup); got != tt.ExpectedOutput { got, err := scenario.Provider.GetConfig(scenario.InputGroup, &scenario.InputAlert)
t.Errorf("AlertProvider.getToForGroup() = %v, want %v", got, tt.ExpectedOutput) if err != nil {
t.Fatalf("unexpected error: %s", err)
}
if got.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = scenario.Provider.ValidateOverrides(scenario.InputGroup, &scenario.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
} }
}) })
} }
@@ -0,0 +1,255 @@
package teamsworkflows
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"`
Title string `yaml:"title,omitempty"` // Title of the message that will be sent
}
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.Title) > 0 {
cfg.Title = override.Title
}
}
// AlertProvider is the configuration necessary for sending an alert using Teams
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
}
buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
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 > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return err
}
// AdaptiveCardBody represents the structure of an Adaptive Card
type AdaptiveCardBody struct {
Type string `json:"type"`
Version string `json:"version"`
Body []CardBody `json:"body"`
MSTeams MSTeamsBody `json:"msteams"`
}
// CardBody represents the body of the Adaptive Card
type CardBody struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Wrap bool `json:"wrap"`
Separator bool `json:"separator,omitempty"`
Size string `json:"size,omitempty"`
Weight string `json:"weight,omitempty"`
Items []CardBody `json:"items,omitempty"`
Facts []Fact `json:"facts,omitempty"`
FactSet *FactSetBody `json:"factSet,omitempty"`
Style string `json:"style,omitempty"`
}
// MSTeamsBody represents the msteams options
type MSTeamsBody struct {
Width string `json:"width"`
}
// FactSetBody represents the FactSet in the Adaptive Card
type FactSetBody struct {
Type string `json:"type"`
Facts []Fact `json:"facts"`
}
// Fact represents an individual fact in the FactSet
type Fact struct {
Title string `json:"title"`
Value string `json:"value"`
}
// 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 {
var message string
var themeColor 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)
themeColor = "Good" // green
} 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)
themeColor = "Attention" // red
}
// Configure default title if it's not provided
title := "⛑️ Gatus"
if cfg.Title != "" {
title = cfg.Title
}
// Build the facts from the condition results
var facts []Fact
for _, conditionResult := range result.ConditionResults {
var key string
if conditionResult.Success {
key = "✅"
} else {
key = "❌"
}
facts = append(facts, Fact{
Title: key,
Value: conditionResult.Condition,
})
}
cardContent := AdaptiveCardBody{
Type: "AdaptiveCard",
Version: "1.4", // Version 1.5 and 1.6 doesn't seem to be supported by Teams as of 27/08/2024
Body: []CardBody{
{
Type: "Container",
Style: themeColor,
Items: []CardBody{
{
Type: "Container",
Style: "Default",
Items: []CardBody{
{
Type: "TextBlock",
Text: title,
Size: "Medium",
Weight: "Bolder",
},
{
Type: "TextBlock",
Text: message,
Wrap: true,
},
{
Type: "FactSet",
Facts: facts,
},
},
},
},
},
},
MSTeams: MSTeamsBody{
Width: "Full",
},
}
attachment := map[string]interface{}{
"contentType": "application/vnd.microsoft.card.adaptive",
"content": cardContent,
}
payload := map[string]interface{}{
"type": "message",
"attachments": []interface{}{attachment},
}
bodyAsJSON, _ := json.Marshal(payload)
return bodyAsJSON
}
// GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert
}
// GetConfig returns the configuration for the provider with the overrides applied
func (provider *AlertProvider) GetConfig(group string, alert *alert.Alert) (*Config, error) {
cfg := provider.DefaultConfig
// Handle group overrides
if provider.Overrides != nil {
for _, override := range provider.Overrides {
if group == override.Group {
cfg.Merge(&override.Config)
break
}
}
}
// Handle alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
@@ -0,0 +1,298 @@
package teamsworkflows
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{WebhookURL: ""}}
if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid")
}
validProvider := AlertProvider{DefaultConfig: Config{WebhookURL: "http://example.com"}}
if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid, got", err.Error())
}
}
func TestAlertProvider_ValidateWithOverride(t *testing.T) {
providerWithInvalidOverrideGroup := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
Group: "",
},
},
}
if err := providerWithInvalidOverrideGroup.Validate(); err == nil {
t.Error("provider Group shouldn't have been valid")
}
providerWithInvalidOverrideTo := AlertProvider{
Overrides: []Override{
{
Config: Config{WebhookURL: ""},
Group: "group",
},
},
}
if err := providerWithInvalidOverrideTo.Validate(); err == nil {
t.Error("provider integration key shouldn't have been valid")
}
providerWithValidOverride := AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Config: Config{WebhookURL: "http://example.com"},
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{WebhookURL: "http://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.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "triggered-error",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://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.StatusInternalServerError, Body: http.NoBody}
}),
ExpectedError: true,
},
{
Name: "resolved",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://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.StatusOK, Body: http.NoBody}
}),
ExpectedError: false,
},
{
Name: "resolved-error",
Provider: AlertProvider{DefaultConfig: Config{WebhookURL: "http://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.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
Alert alert.Alert
NoConditions bool
Resolved bool
ExpectedBody string
}{
{
Name: "triggered",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been triggered due to having failed 3 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"❌\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"❌\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Attention\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved",
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false,\"facts\":[{\"title\":\"✅\",\"value\":\"[CONNECTED] == true\"},{\"title\":\"✅\",\"value\":\"[STATUS] == 200\"}]}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
{
Name: "resolved-with-no-conditions",
NoConditions: true,
Provider: AlertProvider{},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true,
ExpectedBody: "{\"attachments\":[{\"content\":{\"type\":\"AdaptiveCard\",\"version\":\"1.4\",\"body\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"Container\",\"wrap\":false,\"items\":[{\"type\":\"TextBlock\",\"text\":\"⛑️ Gatus\",\"wrap\":false,\"size\":\"Medium\",\"weight\":\"Bolder\"},{\"type\":\"TextBlock\",\"text\":\"An alert for **endpoint-name** has been resolved after passing successfully 5 time(s) in a row.\",\"wrap\":true},{\"type\":\"FactSet\",\"wrap\":false}],\"style\":\"Default\"}],\"style\":\"Good\"}],\"msteams\":{\"width\":\"Full\"}},\"contentType\":\"application/vnd.microsoft.card.adaptive\"}],\"type\":\"message\"}",
},
}
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 := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults},
scenario.Resolved,
)
if string(body) != scenario.ExpectedBody {
t.Errorf("expected:\n%s\ngot:\n%s", scenario.ExpectedBody, body)
}
out := make(map[string]interface{})
if err := json.Unmarshal(body, &out); err != nil {
t.Error("expected body to be valid JSON, got error:", err.Error())
}
})
}
}
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{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-no-override-specify-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://example01.com"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://example.com"},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{WebhookURL: "http://group-example.com"},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{WebhookURL: "http://example.com"},
Overrides: []Override{
{
Group: "group",
Config: Config{WebhookURL: "http://group-example.com"},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{"webhook-url": "http://alert-example.com"}},
ExpectedOutput: Config{WebhookURL: "http://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.WebhookURL != scenario.ExpectedOutput.WebhookURL {
t.Errorf("expected webhook URL to be %s, got %s", scenario.ExpectedOutput.WebhookURL, got.WebhookURL)
}
// 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)
}
})
}
}
+104 -53
View File
@@ -3,6 +3,7 @@ package telegram
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
@@ -10,66 +11,101 @@ import (
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
const defaultAPIURL = "https://api.telegram.org" const defaultApiUrl = "https://api.telegram.org"
var (
ErrTokenNotSet = errors.New("token not set")
ErrIDNotSet = errors.New("id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
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
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
if len(cfg.ID) == 0 {
return ErrIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if override.ClientConfig != nil {
cfg.ClientConfig = override.ClientConfig
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
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
}
}
// AlertProvider is the configuration necessary for sending an alert using Telegram // AlertProvider is the configuration necessary for sending an alert using Telegram
type AlertProvider struct { type AlertProvider struct {
Token string `yaml:"token"` DefaultConfig Config `yaml:",inline"`
ID string `yaml:"id"`
APIURL string `yaml:"api-url"`
// ClientConfig is the configuration of the client used to communicate with the provider's target
ClientConfig *client.Config `yaml:"client,omitempty"`
// DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type // DefaultAlert is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
// Overrides is a list of Overrid that may be prioritized over the default configuration // Overrides is a list of overrides that may be prioritized over the default configuration
Overrides []*Override `yaml:"overrides,omitempty"` Overrides []*Override `yaml:"overrides,omitempty"`
} }
// Override is a configuration that may be prioritized over the default configuration // Override is a configuration that may be prioritized over the default configuration
type Override struct { type Override struct {
group string `yaml:"group"` Group string `yaml:"group"`
token string `yaml:"token"` Config `yaml:",inline"`
id string `yaml:"id"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
if provider.ClientConfig == nil { registeredGroups := make(map[string]bool)
provider.ClientConfig = client.GetDefaultConfig() if provider.Overrides != nil {
} for _, override := range provider.Overrides {
if isAlreadyRegistered := registeredGroups[override.Group]; isAlreadyRegistered || override.Group == "" {
registerGroups := make(map[string]bool) return ErrDuplicateGroupOverride
for _, override := range provider.Overrides { }
if len(override.group) == 0 { registeredGroups[override.Group] = true
return false
} }
if _, ok := registerGroups[override.group]; ok {
return false
}
registerGroups[override.group] = true
} }
return provider.DefaultConfig.Validate()
return len(provider.Token) > 0 && len(provider.ID) > 0
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer(provider.buildRequestBody(ep, alert, result, resolved)) cfg, err := provider.GetConfig(ep.Group, alert)
apiURL := provider.APIURL if err != nil {
if apiURL == "" { return err
apiURL = defaultAPIURL
} }
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", apiURL, provider.getTokenForGroup(ep.Group)), buffer) buffer := bytes.NewBuffer(provider.buildRequestBody(cfg, ep, alert, result, resolved))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("%s/bot%s/sendMessage", cfg.ApiUrl, cfg.Token), buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/json") request.Header.Set("Content-Type", "application/json")
response, err := client.GetHTTPClient(provider.ClientConfig).Do(request) response, err := client.GetHTTPClient(cfg.ClientConfig).Do(request)
if err != nil { if err != nil {
return err return err
} }
@@ -81,23 +117,15 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
return err return err
} }
func (provider *AlertProvider) getTokenForGroup(group string) string {
for _, override := range provider.Overrides {
if override.group == group && len(override.token) > 0 {
return override.token
}
}
return provider.Token
}
type Body struct { type Body struct {
ChatID string `json:"chat_id"` ChatID string `json:"chat_id"`
Text string `json:"text"` Text string `json:"text"`
ParseMode string `json:"parse_mode"` ParseMode string `json:"parse_mode"`
TopicID string `json:"message_thread_id,omitempty"`
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) []byte {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold) message = fmt.Sprintf("An alert for *%s* has been resolved:\n—\n _healthcheck passing successfully %d time(s) in a row_\n— ", ep.DisplayName(), alert.SuccessThreshold)
@@ -124,23 +152,46 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults) text = fmt.Sprintf("⛑ *Gatus* \n%s%s", message, formattedConditionResults)
} }
bodyAsJSON, _ := json.Marshal(Body{ bodyAsJSON, _ := json.Marshal(Body{
ChatID: provider.getIDForGroup(ep.Group), ChatID: cfg.ID,
Text: text, Text: text,
ParseMode: "MARKDOWN", ParseMode: "MARKDOWN",
TopicID: cfg.TopicID,
}) })
return bodyAsJSON return bodyAsJSON
} }
func (provider *AlertProvider) getIDForGroup(group string) string {
for _, override := range provider.Overrides {
if override.group == group && len(override.id) > 0 {
return override.id
}
}
return provider.ID
}
// GetDefaultAlert returns the provider's default alert configuration // GetDefaultAlert returns the provider's default alert configuration
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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
}
+87 -70
View File
@@ -11,87 +11,36 @@ import (
"github.com/TwiN/gatus/v5/test" "github.com/TwiN/gatus/v5/test"
) )
func TestAlertDefaultProvider_IsValid(t *testing.T) { func TestAlertProvider_Validate(t *testing.T) {
t.Run("invalid-provider", func(t *testing.T) { t.Run("invalid-provider", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "", ID: ""} invalidProvider := AlertProvider{DefaultConfig: Config{Token: "", ID: ""}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("valid-provider", func(t *testing.T) { t.Run("valid-provider", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"} validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}}
if validProvider.ClientConfig != nil { if err := validProvider.Validate(); err != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
}) })
}
func TestAlertProvider_IsValidWithOverrides(t *testing.T) {
t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) { t.Run("invalid-provider-override-nonexist-group", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{token: "token", id: "id"}}} invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Config: Config{Token: "token", ID: "id"}}}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) { t.Run("invalid-provider-override-duplicate-group", func(t *testing.T) {
invalidProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group1", token: "token", id: "id"}, {group: "group1", id: "id2"}}} invalidProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group1", Config: Config{Token: "token", ID: "id"}}, {Group: "group1", Config: Config{ID: "id2"}}}}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
}) })
t.Run("valid-provider", func(t *testing.T) { t.Run("valid-provider-with-overrides", func(t *testing.T) {
validProvider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "token", id: "id"}}} validProvider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "token", ID: "id"}}}}
if validProvider.ClientConfig != nil { if err := validProvider.Validate(); err != nil {
t.Error("provider client config should have been nil prior to IsValid() being executed")
}
if !validProvider.IsValid() {
t.Error("provider should've been valid") t.Error("provider should've been valid")
} }
if validProvider.ClientConfig == nil {
t.Error("provider client config should have been set after IsValid() was executed")
}
})
}
func TestAlertProvider_getTokenAndIDForGroup(t *testing.T) {
t.Run("get-token-with-override", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken", id: "overrideID"}}}
token := provider.getTokenForGroup("group")
if token != "overrideToken" {
t.Error("token should have been 'overrideToken'")
}
id := provider.getIDForGroup("group")
if id != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", id: "overrideID"}}}
token := provider.getTokenForGroup("group")
if token != provider.Token {
t.Error("token should have been the default token")
}
id := provider.getIDForGroup("group")
if id != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
provider := AlertProvider{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678", Overrides: []*Override{{group: "group", token: "overrideToken"}}}
token := provider.getTokenForGroup("group")
if token != "overrideToken" {
t.Error("token should have been 'overrideToken'")
}
id := provider.getIDForGroup("group")
if id != provider.ID {
t.Error("id should have been the default id")
}
}) })
} }
@@ -109,7 +58,7 @@ func TestAlertProvider_Send(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -119,7 +68,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "triggered-error", Name: "triggered-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -129,7 +78,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -139,7 +88,7 @@ func TestAlertProvider_Send(t *testing.T) {
}, },
{ {
Name: "resolved-error", Name: "resolved-error",
Provider: AlertProvider{}, Provider: AlertProvider{DefaultConfig: Config{ID: "123", Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response { MockRoundTripper: test.MockRoundTripper(func(r *http.Request) *http.Response {
@@ -185,14 +134,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, 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\"}", 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\"}",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}", 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\\n*Condition results*\\n✅ - `[CONNECTED] == true`\\n✅ - `[STATUS] == 200`\\n\",\"parse_mode\":\"MARKDOWN\"}",
@@ -200,11 +149,18 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
{ {
Name: "resolved-with-no-conditions", Name: "resolved-with-no-conditions",
NoConditions: true, NoConditions: true,
Provider: AlertProvider{ID: "123"}, Provider: AlertProvider{DefaultConfig: Config{ID: "123"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, 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\"}", 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 { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
@@ -216,6 +172,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
} }
} }
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ConditionResults: conditionResults}, &endpoint.Result{ConditionResults: conditionResults},
@@ -240,3 +197,63 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
t.Run("get-token-with-override", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken", ID: "overrideID"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "groupToken" {
t.Error("token should have been 'groupToken'")
}
if cfg.ID != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-id", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{ID: "overrideID"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != provider.DefaultConfig.Token {
t.Error("token should have been the default token")
}
if cfg.ID != "overrideID" {
t.Error("id should have been 'overrideID'")
}
})
t.Run("get-default-token-with-overridden-token", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
cfg, err := provider.GetConfig("group", &alert.Alert{})
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "groupToken" {
t.Error("token should have been 'groupToken'")
}
if cfg.ID != provider.DefaultConfig.ID {
t.Error("id should have been the default id")
}
})
t.Run("get-default-token-with-overridden-token-and-alert-token-override", func(t *testing.T) {
provider := AlertProvider{DefaultConfig: Config{Token: "123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11", ID: "12345678"}, Overrides: []*Override{{Group: "group", Config: Config{Token: "groupToken"}}}}
alert := &alert.Alert{ProviderOverride: map[string]any{"token": "alertToken"}}
cfg, err := provider.GetConfig("group", alert)
if err != nil {
t.Error("expected no error, got", err)
}
if cfg.Token != "alertToken" {
t.Error("token should have been 'alertToken'")
}
if cfg.ID != provider.DefaultConfig.ID {
t.Error("id should have been the default id")
}
// Test ValidateOverrides as well, since it really just calls GetConfig
if err = provider.ValidateOverrides("group", alert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
+101 -13
View File
@@ -3,41 +3,99 @@ package twilio
import ( import (
"bytes" "bytes"
"encoding/base64" "encoding/base64"
"errors"
"fmt" "fmt"
"io" "io"
"net/http" "net/http"
"net/url" "net/url"
"strings"
"github.com/TwiN/gatus/v5/alerting/alert" "github.com/TwiN/gatus/v5/alerting/alert"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"gopkg.in/yaml.v3"
) )
// AlertProvider is the configuration necessary for sending an alert using Twilio var (
type AlertProvider struct { ErrSIDNotSet = errors.New("sid not set")
ErrTokenNotSet = errors.New("token not set")
ErrFromNotSet = errors.New("from not set")
ErrToNotSet = errors.New("to not set")
)
type Config struct {
SID string `yaml:"sid"` SID string `yaml:"sid"`
Token string `yaml:"token"` Token string `yaml:"token"`
From string `yaml:"from"` From string `yaml:"from"`
To string `yaml:"to"` To string `yaml:"to"`
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)
}
func (cfg *Config) Validate() error {
if len(cfg.SID) == 0 {
return ErrSIDNotSet
}
if len(cfg.Token) == 0 {
return ErrTokenNotSet
}
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.SID) > 0 {
cfg.SID = override.SID
}
if len(override.Token) > 0 {
cfg.Token = override.Token
}
if len(override.From) > 0 {
cfg.From = override.From
}
if len(override.To) > 0 {
cfg.To = override.To
}
if len(override.TextTwilioTriggered) > 0 {
cfg.TextTwilioTriggered = override.TextTwilioTriggered
}
if len(override.TextTwilioResolved) > 0 {
cfg.TextTwilioResolved = override.TextTwilioResolved
}
}
// AlertProvider is the configuration necessary for sending an alert using Twilio
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 is the default alert configuration to use for endpoints with an alert of the appropriate type
DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"` DefaultAlert *alert.Alert `yaml:"default-alert,omitempty"`
} }
// IsValid returns whether the provider's configuration is valid // Validate the provider's configuration
func (provider *AlertProvider) IsValid() bool { func (provider *AlertProvider) Validate() error {
return len(provider.Token) > 0 && len(provider.SID) > 0 && len(provider.From) > 0 && len(provider.To) > 0 return provider.DefaultConfig.Validate()
} }
// Send an alert using the provider // Send an alert using the provider
func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error { func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) error {
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(ep, alert, result, resolved))) cfg, err := provider.GetConfig(ep.Group, alert)
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", provider.SID), buffer) if err != nil {
return err
}
buffer := bytes.NewBuffer([]byte(provider.buildRequestBody(cfg, ep, alert, result, resolved)))
request, err := http.NewRequest(http.MethodPost, fmt.Sprintf("https://api.twilio.com/2010-04-01/Accounts/%s/Messages.json", cfg.SID), buffer)
if err != nil { if err != nil {
return err return err
} }
request.Header.Set("Content-Type", "application/x-www-form-urlencoded") request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(provider.SID+":"+provider.Token)))) request.Header.Set("Authorization", fmt.Sprintf("Basic %s", base64.StdEncoding.EncodeToString([]byte(cfg.SID+":"+cfg.Token))))
response, err := client.GetHTTPClient(nil).Do(request) response, err := client.GetHTTPClient(nil).Do(request)
if err != nil { if err != nil {
return err return err
@@ -51,16 +109,24 @@ func (provider *AlertProvider) Send(ep *endpoint.Endpoint, alert *alert.Alert, r
} }
// buildRequestBody builds the request body for the provider // buildRequestBody builds the request body for the provider
func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string { func (provider *AlertProvider) buildRequestBody(cfg *Config, ep *endpoint.Endpoint, alert *alert.Alert, result *endpoint.Result, resolved bool) string {
var message string var message string
if resolved { if resolved {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription()) if len(cfg.TextTwilioResolved) > 0 {
message = strings.Replace(strings.Replace(cfg.TextTwilioResolved, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("RESOLVED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
} else { } else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription()) if len(cfg.TextTwilioTriggered) > 0 {
message = strings.Replace(strings.Replace(cfg.TextTwilioTriggered, "{endpoint}", ep.DisplayName(), 1), "{description}", alert.GetDescription(), 1)
} else {
message = fmt.Sprintf("TRIGGERED: %s - %s", ep.DisplayName(), alert.GetDescription())
}
} }
return url.Values{ return url.Values{
"To": {provider.To}, "To": {cfg.To},
"From": {provider.From}, "From": {cfg.From},
"Body": {message}, "Body": {message},
}.Encode() }.Encode()
} }
@@ -69,3 +135,25 @@ func (provider *AlertProvider) buildRequestBody(ep *endpoint.Endpoint, alert *al
func (provider *AlertProvider) GetDefaultAlert() *alert.Alert { func (provider *AlertProvider) GetDefaultAlert() *alert.Alert {
return provider.DefaultAlert 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 alert overrides
if len(alert.ProviderOverride) != 0 {
overrideConfig := Config{}
if err := yaml.Unmarshal(alert.ProviderOverrideAsBytes(), &overrideConfig); err != nil {
return nil, err
}
cfg.Merge(&overrideConfig)
}
// Validate the configuration
err := cfg.Validate()
return &cfg, err
}
// ValidateOverrides validates the alert's provider override and, if present, the group override
func (provider *AlertProvider) ValidateOverrides(group string, alert *alert.Alert) error {
_, err := provider.GetConfig(group, alert)
return err
}
+141 -8
View File
@@ -1,28 +1,110 @@
package twilio package twilio
import ( import (
"net/http"
"testing" "testing"
"github.com/TwiN/gatus/v5/alerting/alert" "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/config/endpoint"
"github.com/TwiN/gatus/v5/test"
) )
func TestTwilioAlertProvider_IsValid(t *testing.T) { func TestTwilioAlertProvider_IsValid(t *testing.T) {
invalidProvider := AlertProvider{} invalidProvider := AlertProvider{}
if invalidProvider.IsValid() { if err := invalidProvider.Validate(); err == nil {
t.Error("provider shouldn't have been valid") t.Error("provider shouldn't have been valid")
} }
validProvider := AlertProvider{ validProvider := AlertProvider{
SID: "1", DefaultConfig: Config{
Token: "1", SID: "1",
From: "1", Token: "1",
To: "1", From: "1",
To: "1",
},
} }
if !validProvider.IsValid() { if err := validProvider.Validate(); err != nil {
t.Error("provider should've been valid") 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{SID: "1", Token: "2", From: "3", To: "4"}},
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{SID: "1", Token: "2", From: "3", To: "4"}},
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{SID: "1", Token: "2", From: "3", To: "4"}},
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{SID: "1", Token: "2", From: "3", To: "4"}},
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) { func TestAlertProvider_buildRequestBody(t *testing.T) {
firstDescription := "description-1" firstDescription := "description-1"
secondDescription := "description-2" secondDescription := "description-2"
@@ -35,14 +117,14 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
}{ }{
{ {
Name: "triggered", Name: "triggered",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"}, Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &firstDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: false, Resolved: false,
ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4", ExpectedBody: "Body=TRIGGERED%3A+endpoint-name+-+description-1&From=3&To=4",
}, },
{ {
Name: "resolved", Name: "resolved",
Provider: AlertProvider{SID: "1", Token: "2", From: "3", To: "4"}, Provider: AlertProvider{DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"}},
Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3}, Alert: alert.Alert{Description: &secondDescription, SuccessThreshold: 5, FailureThreshold: 3},
Resolved: true, Resolved: true,
ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4", ExpectedBody: "Body=RESOLVED%3A+endpoint-name+-+description-2&From=3&To=4",
@@ -51,6 +133,7 @@ func TestAlertProvider_buildRequestBody(t *testing.T) {
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
body := scenario.Provider.buildRequestBody( body := scenario.Provider.buildRequestBody(
&scenario.Provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-name"}, &endpoint.Endpoint{Name: "endpoint-name"},
&scenario.Alert, &scenario.Alert,
&endpoint.Result{ &endpoint.Result{
@@ -76,3 +159,53 @@ func TestAlertProvider_GetDefaultAlert(t *testing.T) {
t.Error("expected default alert to be nil") t.Error("expected default alert to be nil")
} }
} }
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-override-should-default",
Provider: AlertProvider{
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
InputAlert: alert.Alert{},
ExpectedOutput: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
{
Name: "provider-with-alert-override",
Provider: AlertProvider{
DefaultConfig: Config{SID: "1", Token: "2", From: "3", To: "4"},
},
InputAlert: alert.Alert{ProviderOverride: map[string]any{"sid": "5", "token": "6", "from": "7", "to": "8"}},
ExpectedOutput: Config{SID: "5", Token: "6", From: "7", To: "8"},
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
got, err := scenario.Provider.GetConfig("", &scenario.InputAlert)
if err != nil {
t.Error("expected no error, got:", err.Error())
}
if got.SID != scenario.ExpectedOutput.SID {
t.Errorf("expected SID to be %s, got %s", scenario.ExpectedOutput.SID, got.SID)
}
if got.Token != scenario.ExpectedOutput.Token {
t.Errorf("expected token to be %s, got %s", scenario.ExpectedOutput.Token, got.Token)
}
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.InputAlert); err != nil {
t.Errorf("unexpected error: %s", err)
}
})
}
}
+183
View File
@@ -0,0 +1,183 @@
package zulip
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"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 (
ErrBotEmailNotSet = errors.New("bot-email not set")
ErrBotAPIKeyNotSet = errors.New("bot-api-key not set")
ErrDomainNotSet = errors.New("domain not set")
ErrChannelIDNotSet = errors.New("channel-id not set")
ErrDuplicateGroupOverride = errors.New("duplicate group override")
)
type Config struct {
BotEmail string `yaml:"bot-email"` // Email of the bot user
BotAPIKey string `yaml:"bot-api-key"` // API key of the bot user
Domain string `yaml:"domain"` // Domain of the Zulip server
ChannelID string `yaml:"channel-id"` // ID of the channel to send the message to
}
func (cfg *Config) Validate() error {
if len(cfg.BotEmail) == 0 {
return ErrBotEmailNotSet
}
if len(cfg.BotAPIKey) == 0 {
return ErrBotAPIKeyNotSet
}
if len(cfg.Domain) == 0 {
return ErrDomainNotSet
}
if len(cfg.ChannelID) == 0 {
return ErrChannelIDNotSet
}
return nil
}
func (cfg *Config) Merge(override *Config) {
if len(override.BotEmail) > 0 {
cfg.BotEmail = override.BotEmail
}
if len(override.BotAPIKey) > 0 {
cfg.BotAPIKey = override.BotAPIKey
}
if len(override.Domain) > 0 {
cfg.Domain = override.Domain
}
if len(override.ChannelID) > 0 {
cfg.ChannelID = override.ChannelID
}
}
// AlertProvider is the configuration necessary for sending an alert using Zulip
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
}
buffer := bytes.NewBufferString(provider.buildRequestBody(cfg, ep, alert, result, resolved))
zulipEndpoint := fmt.Sprintf("https://%s/api/v1/messages", cfg.Domain)
request, err := http.NewRequest(http.MethodPost, zulipEndpoint, buffer)
if err != nil {
return err
}
request.SetBasicAuth(cfg.BotEmail, cfg.BotAPIKey)
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("User-Agent", "Gatus")
response, err := client.GetHTTPClient(nil).Do(request)
if err != nil {
return err
}
defer response.Body.Close()
if response.StatusCode > 399 {
body, _ := io.ReadAll(response.Body)
return fmt.Errorf("call to provider alert returned status code %d: %s", response.StatusCode, string(body))
}
return nil
}
// 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) string {
var message 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)
} 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)
}
if alertDescription := alert.GetDescription(); len(alertDescription) > 0 {
message += "\n> " + alertDescription + "\n"
}
for _, conditionResult := range result.ConditionResults {
var prefix string
if conditionResult.Success {
prefix = ":check:"
} else {
prefix = ":cross_mark:"
}
message += fmt.Sprintf("\n%s - `%s`", prefix, conditionResult.Condition)
}
return url.Values{
"type": {"channel"},
"to": {cfg.ChannelID},
"topic": {"Gatus"},
"content": {message},
}.Encode()
}
// 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
}
+482
View File
@@ -0,0 +1,482 @@
package zulip
import (
"errors"
"fmt"
"net/http"
"net/url"
"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
AlertProvider AlertProvider
ExpectedError error
}{
{
Name: "Empty provider",
AlertProvider: AlertProvider{},
ExpectedError: ErrBotEmailNotSet,
},
{
Name: "Empty channel id",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
},
},
ExpectedError: ErrChannelIDNotSet,
},
{
Name: "Empty domain",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
ChannelID: "something",
},
},
ExpectedError: ErrDomainNotSet,
},
{
Name: "Empty bot api key",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: ErrBotAPIKeyNotSet,
},
{
Name: "Empty bot email",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: ErrBotEmailNotSet,
},
{
Name: "Valid provider",
AlertProvider: AlertProvider{
DefaultConfig: Config{
BotEmail: "something",
BotAPIKey: "something",
Domain: "something",
ChannelID: "something",
},
},
ExpectedError: nil,
},
}
for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) {
if err := scenario.AlertProvider.Validate(); !errors.Is(err, scenario.ExpectedError) {
t.Errorf("ExpectedError error %v, got %v", scenario.ExpectedError, err)
}
})
}
}
func TestAlertProvider_buildRequestBody(t *testing.T) {
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "domain",
ChannelID: "channel-id",
}
alertDesc := "Description"
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
Description: &alertDesc,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
hasConditions bool
expectedBody url.Values
}{
{
name: "Resolved alert with no conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Resolved alert with conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been resolved after passing successfully 2 time(s) in a row
> Description
:check: - ` + "`[CONNECTED] == true`" + `
:check: - ` + "`[STATUS] == 200`" + `
:check: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with no conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: false,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
`},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
{
name: "Failed alert with conditions",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
hasConditions: true,
expectedBody: url.Values{
"content": {`An alert for **endpoint-Name** has been triggered due to having failed 3 time(s) in a row
> Description
:cross_mark: - ` + "`[CONNECTED] == true`" + `
:cross_mark: - ` + "`[STATUS] == 200`" + `
:cross_mark: - ` + "`[BODY] != \"\"`"},
"to": {"channel-id"},
"topic": {"Gatus"},
"type": {"channel"},
},
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
var conditionResults []*endpoint.ConditionResult
if tc.hasConditions {
conditionResults = []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
{Condition: "[BODY] != \"\"", Success: tc.resolved},
}
}
body := tc.provider.buildRequestBody(
&tc.provider.DefaultConfig,
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: conditionResults,
},
tc.resolved,
)
valuesResult, err := url.ParseQuery(body)
if err != nil {
t.Error(err)
}
if fmt.Sprintf("%v", valuesResult) != fmt.Sprintf("%v", tc.expectedBody) {
t.Errorf("Expected body:\n%v\ngot:\n%v", tc.expectedBody, valuesResult)
}
})
}
}
func TestAlertProvider_GetDefaultAlert(t *testing.T) {
if (&AlertProvider{DefaultAlert: &alert.Alert{}}).GetDefaultAlert() == nil {
t.Error("ExpectedError default alert to be not nil")
}
if (&AlertProvider{DefaultAlert: nil}).GetDefaultAlert() != nil {
t.Error("ExpectedError default alert to be nil")
}
}
func TestAlertProvider_Send(t *testing.T) {
defer client.InjectHTTPClient(nil)
validateRequest := func(req *http.Request) {
if req.URL.String() != "https://custom-domain/api/v1/messages" {
t.Errorf("ExpectedError url https://custom-domain.zulipchat.com/api/v1/messages, got %s", req.URL.String())
}
if req.Method != http.MethodPost {
t.Errorf("ExpectedError POST request, got %s", req.Method)
}
if req.Header.Get("Content-Type") != "application/x-www-form-urlencoded" {
t.Errorf("ExpectedError Content-Type header to be application/x-www-form-urlencoded, got %s", req.Header.Get("Content-Type"))
}
if req.Header.Get("User-Agent") != "Gatus" {
t.Errorf("ExpectedError User-Agent header to be Gatus, got %s", req.Header.Get("User-Agent"))
}
}
basicConfig := Config{
BotEmail: "bot-email",
BotAPIKey: "bot-api-key",
Domain: "custom-domain",
ChannelID: "channel-id",
}
basicAlert := alert.Alert{
SuccessThreshold: 2,
FailureThreshold: 3,
}
testCases := []struct {
name string
provider AlertProvider
alert alert.Alert
resolved bool
mockRoundTripper test.MockRoundTripper
expectedError bool
}{
{
name: "resolved",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "resolved error",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: true,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
{
name: "triggered",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusOK}
}),
expectedError: false,
},
{
name: "triggered error",
provider: AlertProvider{
DefaultConfig: basicConfig,
},
alert: basicAlert,
resolved: false,
mockRoundTripper: test.MockRoundTripper(func(req *http.Request) *http.Response {
validateRequest(req)
return &http.Response{StatusCode: http.StatusInternalServerError}
}),
expectedError: true,
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
client.InjectHTTPClient(&http.Client{Transport: tc.mockRoundTripper})
err := tc.provider.Send(
&endpoint.Endpoint{Name: "endpoint-Name"},
&tc.alert,
&endpoint.Result{
ConditionResults: []*endpoint.ConditionResult{
{Condition: "[CONNECTED] == true", Success: tc.resolved},
{Condition: "[STATUS] == 200", Success: tc.resolved},
},
},
tc.resolved,
)
if tc.expectedError && err == nil {
t.Error("ExpectedError error, got none")
}
if !tc.expectedError && err != nil {
t.Errorf("ExpectedError no error, got: %v", err)
}
})
}
}
func TestAlertProvider_GetConfig(t *testing.T) {
scenarios := []struct {
Name string
Provider AlertProvider
InputGroup string
InputAlert alert.Alert
ExpectedOutput Config
}{
{
Name: "provider-no-overrides",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: nil,
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
},
{
Name: "provider-with-override-specify-no-group-should-default",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{ChannelID: "group-channel-id"},
},
},
},
InputGroup: "",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
},
{
Name: "provider-with-override-specify-group-should-override",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{},
ExpectedOutput: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
{
Name: "provider-with-group-override-and-alert-override--alert-override-should-take-precedence",
Provider: AlertProvider{
DefaultConfig: Config{
BotEmail: "default-bot-email",
BotAPIKey: "default-bot-api-key",
Domain: "default-domain",
ChannelID: "default-channel-id",
},
Overrides: []Override{
{
Group: "group",
Config: Config{
BotEmail: "group-bot-email",
BotAPIKey: "group-bot-api-key",
Domain: "group-domain",
ChannelID: "group-channel-id",
},
},
},
},
InputGroup: "group",
InputAlert: alert.Alert{ProviderOverride: map[string]any{
"bot-email": "alert-bot-email",
"bot-api-key": "alert-bot-api-key",
"domain": "alert-domain",
"channel-id": "alert-channel-id",
}},
ExpectedOutput: Config{
BotEmail: "alert-bot-email",
BotAPIKey: "alert-bot-api-key",
Domain: "alert-domain",
ChannelID: "alert-channel-id",
},
},
}
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.BotEmail != scenario.ExpectedOutput.BotEmail {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotEmail, got.BotEmail)
}
if got.BotAPIKey != scenario.ExpectedOutput.BotAPIKey {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.BotAPIKey, got.BotAPIKey)
}
if got.Domain != scenario.ExpectedOutput.Domain {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.Domain, got.Domain)
}
if got.ChannelID != scenario.ExpectedOutput.ChannelID {
t.Errorf("expected %s, got %s", scenario.ExpectedOutput.ChannelID, got.ChannelID)
}
// 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)
}
})
}
}
+14 -5
View File
@@ -2,14 +2,15 @@ package api
import ( import (
"io/fs" "io/fs"
"log"
"net/http" "net/http"
"os" "os"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/ui"
"github.com/TwiN/gatus/v5/config/web" "github.com/TwiN/gatus/v5/config/web"
static "github.com/TwiN/gatus/v5/web" static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/health" "github.com/TwiN/health"
"github.com/TwiN/logr"
fiber "github.com/gofiber/fiber/v2" fiber "github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/adaptor" "github.com/gofiber/fiber/v2/middleware/adaptor"
"github.com/gofiber/fiber/v2/middleware/compress" "github.com/gofiber/fiber/v2/middleware/compress"
@@ -28,9 +29,13 @@ type API struct {
func New(cfg *config.Config) *API { func New(cfg *config.Config) *API {
api := &API{} api := &API{}
if cfg.Web == nil { if cfg.Web == nil {
log.Println("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration") logr.Warnf("[api.New] nil web config passed as parameter. This should only happen in tests. Using default web configuration")
cfg.Web = web.GetDefaultConfig() cfg.Web = web.GetDefaultConfig()
} }
if cfg.UI == nil {
logr.Warnf("[api.New] nil ui config passed as parameter. This should only happen in tests. Using default ui configuration")
cfg.UI = ui.GetDefaultConfig()
}
api.router = api.createRouter(cfg) api.router = api.createRouter(cfg)
return api return api
} }
@@ -42,7 +47,7 @@ func (a *API) Router() *fiber.App {
func (a *API) createRouter(cfg *config.Config) *fiber.App { func (a *API) createRouter(cfg *config.Config) *fiber.App {
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
ErrorHandler: func(c *fiber.Ctx, err error) error { ErrorHandler: func(c *fiber.Ctx, err error) error {
log.Printf("[api.ErrorHandler] %s", err.Error()) logr.Errorf("[api.ErrorHandler] %s", err.Error())
return fiber.DefaultErrorHandler(c, err) return fiber.DefaultErrorHandler(c, err)
}, },
ReadBufferSize: cfg.Web.ReadBufferSize, ReadBufferSize: cfg.Web.ReadBufferSize,
@@ -70,10 +75,12 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
// UNPROTECTED ROUTES // // UNPROTECTED ROUTES //
//////////////////////// ////////////////////////
unprotectedAPIRouter := apiRouter.Group("/") 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.svg", HealthBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields) unprotectedAPIRouter.Get("/v1/endpoints/:key/health/badge.shields", HealthBadgeShields)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration", UptimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge) unprotectedAPIRouter.Get("/v1/endpoints/:key/uptimes/:duration/badge.svg", UptimeBadge)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration", ResponseTimeRaw)
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg)) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/badge.svg", ResponseTimeBadge(cfg))
unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart) unprotectedAPIRouter.Get("/v1/endpoints/:key/response-times/:duration/chart.svg", ResponseTimeChart)
// This endpoint requires authz with bearer token, so technically it is protected // This endpoint requires authz with bearer token, so technically it is protected
@@ -87,6 +94,8 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
statusCode, body := healthHandler.GetResponseStatusCodeAndBody() statusCode, body := healthHandler.GetResponseStatusCodeAndBody()
return c.Status(statusCode).Send(body) return c.Status(statusCode).Send(body)
}) })
// Custom CSS
app.Get("/css/custom.css", CustomCSSHandler{customCSS: cfg.UI.CustomCSS}.GetCustomCSS)
// Everything else falls back on static content // Everything else falls back on static content
app.Use(redirect.New(redirect.Config{ app.Use(redirect.New(redirect.Config{
Rules: map[string]string{ Rules: map[string]string{
@@ -117,6 +126,6 @@ func (a *API) createRouter(cfg *config.Config) *fiber.App {
} }
} }
protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg)) protectedAPIRouter.Get("/v1/endpoints/statuses", EndpointStatuses(cfg))
protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus) protectedAPIRouter.Get("/v1/endpoints/:key/statuses", EndpointStatus(cfg))
return app return app
} }
+11
View File
@@ -25,6 +25,17 @@ func TestNew(t *testing.T) {
Path: "/health", Path: "/health",
ExpectedCode: fiber.StatusOK, ExpectedCode: fiber.StatusOK,
}, },
{
Name: "custom.css",
Path: "/css/custom.css",
ExpectedCode: fiber.StatusOK,
},
{
Name: "custom.css-gzipped",
Path: "/css/custom.css",
ExpectedCode: fiber.StatusOK,
Gzip: true,
},
{ {
Name: "metrics", Name: "metrics",
Path: "/metrics", Path: "/metrics",
+17 -4
View File
@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"net/url"
"strconv" "strconv"
"strings" "strings"
"time" "time"
@@ -53,7 +54,10 @@ func UptimeBadge(c *fiber.Ctx) error {
default: default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
} }
key := c.Params("key") key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now()) uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
@@ -88,7 +92,10 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
default: default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
} }
key := c.Params("key") key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now()) averageResponseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
@@ -107,7 +114,10 @@ func ResponseTimeBadge(cfg *config.Config) fiber.Handler {
// HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed. // HealthBadge handles the automatic generation of badge based on the group name and endpoint name passed.
func HealthBadge(c *fiber.Ctx) error { func HealthBadge(c *fiber.Ctx) error {
key := c.Params("key") key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
pagingConfig := paging.NewEndpointStatusParams() pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1)) status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil { if err != nil {
@@ -133,7 +143,10 @@ func HealthBadge(c *fiber.Ctx) error {
} }
func HealthBadgeShields(c *fiber.Ctx) error { func HealthBadgeShields(c *fiber.Ctx) error {
key := c.Params("key") key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
pagingConfig := paging.NewEndpointStatusParams() pagingConfig := paging.NewEndpointStatusParams()
status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1)) status, err := store.Get().GetEndpointStatusByKey(key, pagingConfig.WithResults(1, 1))
if err != nil { if err != nil {
+8 -3
View File
@@ -2,14 +2,15 @@ package api
import ( import (
"errors" "errors"
"log"
"math" "math"
"net/http" "net/http"
"net/url"
"sort" "sort"
"time" "time"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/wcharczuk/go-chart/v2" "github.com/wcharczuk/go-chart/v2"
"github.com/wcharczuk/go-chart/v2/drawing" "github.com/wcharczuk/go-chart/v2/drawing"
@@ -45,7 +46,11 @@ func ResponseTimeChart(c *fiber.Ctx) error {
default: default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h") return c.Status(400).SendString("Durations supported: 30d, 7d, 24h")
} }
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(c.Params("key"), from, time.Now()) key, err := url.QueryUnescape(c.Params("key"))
if err != nil {
return c.Status(400).SendString("invalid key encoding")
}
hourlyAverageResponseTime, err := store.Get().GetHourlyAverageResponseTimeByKey(key, from, time.Now())
if err != nil { if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) return c.Status(404).SendString(err.Error())
@@ -116,7 +121,7 @@ func ResponseTimeChart(c *fiber.Ctx) error {
c.Set("Expires", "0") c.Set("Expires", "0")
c.Status(http.StatusOK) c.Status(http.StatusOK)
if err := graph.Render(chart.SVG, c); err != nil { if err := graph.Render(chart.SVG, c); err != nil {
log.Println("[api.ResponseTimeChart] Failed to render response time chart:", err.Error()) logr.Errorf("[api.ResponseTimeChart] Failed to render response time chart: %s", err.Error())
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
} }
return nil return nil
+22 -3
View File
@@ -1,14 +1,17 @@
package api package api
import ( import (
"encoding/json"
"fmt" "fmt"
"github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/security" "github.com/TwiN/gatus/v5/security"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
type ConfigHandler struct { type ConfigHandler struct {
securityConfig *security.Config securityConfig *security.Config
config *config.Config
} }
func (handler ConfigHandler) GetConfig(c *fiber.Ctx) error { 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 hasOIDC = handler.securityConfig.OIDC != nil
isAuthenticated = handler.securityConfig.IsAuthenticated(c) 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") c.Set("Content-Type", "application/json")
return c.Status(200). responseBytes, err := json.Marshal(response)
SendString(fmt.Sprintf(`{"oidc":%v,"authenticated":%v}`, hasOIDC, isAuthenticated)) if err != nil {
return c.Status(500).SendString(fmt.Sprintf(`{"error":"Failed to marshal response: %s"}`, err.Error()))
}
return c.Status(200).Send(responseBytes)
} }
+2 -2
View File
@@ -40,7 +40,7 @@ func TestConfigHandler_ServeHTTP(t *testing.T) {
if err != nil { if err != nil {
t.Error("expected err to be nil, but was", err) t.Error("expected err to be nil, but was", err)
} }
if string(body) != `{"oidc":true,"authenticated":false}` { if string(body) != `{"announcements":[],"authenticated":false,"oidc":true}` {
t.Error("expected body to be `{\"oidc\":true,\"authenticated\":false}`, but was", string(body)) t.Error("expected body to be `{\"announcements\":[],\"authenticated\":false,\"oidc\":true}`, but was", string(body))
} }
} }
+14
View File
@@ -0,0 +1,14 @@
package api
import (
"github.com/gofiber/fiber/v2"
)
type CustomCSSHandler struct {
customCSS string
}
func (handler CustomCSSHandler) GetCustomCSS(c *fiber.Ctx) error {
c.Set("Content-Type", "text/css")
return c.Status(200).SendString(handler.customCSS)
}
+40 -26
View File
@@ -4,7 +4,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log" "net/url"
"github.com/TwiN/gatus/v5/client" "github.com/TwiN/gatus/v5/client"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
@@ -13,6 +13,7 @@ import (
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/storage/store/common/paging" "github.com/TwiN/gatus/v5/storage/store/common/paging"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
@@ -20,25 +21,25 @@ import (
// Due to how intensive this operation can be on the storage, this function leverages a cache. // Due to how intensive this operation can be on the storage, this function leverages a cache.
func EndpointStatuses(cfg *config.Config) fiber.Handler { func EndpointStatuses(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
page, pageSize := extractPageAndPageSizeFromRequest(c) page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize)) value, exists := cache.Get(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize))
var data []byte var data []byte
if !exists { if !exists {
endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize)) endpointStatuses, err := store.Get().GetAllEndpointStatuses(paging.NewEndpointStatusParams().WithResults(page, pageSize))
if err != nil { if err != nil {
log.Printf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error()) logr.Errorf("[api.EndpointStatuses] Failed to retrieve endpoint statuses: %s", err.Error())
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
} }
// ALPHA: Retrieve endpoint statuses from remote instances // ALPHA: Retrieve endpoint statuses from remote instances
if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil { if endpointStatusesFromRemote, err := getEndpointStatusesFromRemoteInstances(cfg.Remote); err != nil {
log.Printf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error()) logr.Errorf("[handler.EndpointStatuses] Silently failed to retrieve endpoint statuses from remote: %s", err.Error())
} else if endpointStatusesFromRemote != nil { } else if endpointStatusesFromRemote != nil {
endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...) endpointStatuses = append(endpointStatuses, endpointStatusesFromRemote...)
} }
// Marshal endpoint statuses to JSON // Marshal endpoint statuses to JSON
data, err = json.Marshal(endpointStatuses) data, err = json.Marshal(endpointStatuses)
if err != nil { if err != nil {
log.Printf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error()) logr.Errorf("[api.EndpointStatuses] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON") return c.Status(500).SendString("unable to marshal object to JSON")
} }
cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL) cache.SetWithTTL(fmt.Sprintf("endpoint-status-%d-%d", page, pageSize), data, cacheTTL)
@@ -59,12 +60,14 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
for _, instance := range remoteConfig.Instances { for _, instance := range remoteConfig.Instances {
response, err := httpClient.Get(instance.URL) response, err := httpClient.Get(instance.URL)
if err != nil { if err != nil {
return nil, err // Log the error but continue with other instances
logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error())
continue
} }
var endpointStatuses []*endpoint.Status var endpointStatuses []*endpoint.Status
if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil { if err = json.NewDecoder(response.Body).Decode(&endpointStatuses); err != nil {
_ = response.Body.Close() _ = response.Body.Close()
log.Printf("[api.getEndpointStatusesFromRemoteInstances] Silently failed to retrieve endpoint statuses from %s: %s", instance.URL, err.Error()) logr.Errorf("[api.getEndpointStatusesFromRemoteInstances] Failed to decode endpoint statuses from %s: %s", instance.URL, err.Error())
continue continue
} }
_ = response.Body.Close() _ = response.Body.Close()
@@ -73,29 +76,40 @@ func getEndpointStatusesFromRemoteInstances(remoteConfig *remote.Config) ([]*end
} }
endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...) endpointStatusesFromAllRemotes = append(endpointStatusesFromAllRemotes, endpointStatuses...)
} }
// Only return nil, error if no remote instances were successfully processed
if len(endpointStatusesFromAllRemotes) == 0 && remoteConfig.Instances != nil {
return nil, fmt.Errorf("failed to retrieve endpoint statuses from all remote instances")
}
return endpointStatusesFromAllRemotes, nil return endpointStatusesFromAllRemotes, nil
} }
// EndpointStatus retrieves a single endpoint.Status by group and endpoint name // EndpointStatus retrieves a single endpoint.Status by group and endpoint name
func EndpointStatus(c *fiber.Ctx) error { func EndpointStatus(cfg *config.Config) fiber.Handler {
page, pageSize := extractPageAndPageSizeFromRequest(c) return func(c *fiber.Ctx) error {
endpointStatus, err := store.Get().GetEndpointStatusByKey(c.Params("key"), paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, common.MaximumNumberOfEvents)) page, pageSize := extractPageAndPageSizeFromRequest(c, cfg.Storage.MaximumNumberOfResults)
if err != nil { key, err := url.QueryUnescape(c.Params("key"))
if errors.Is(err, common.ErrEndpointNotFound) { if err != nil {
return c.Status(404).SendString(err.Error()) logr.Errorf("[api.EndpointStatus] Failed to decode key: %s", err.Error())
return c.Status(400).SendString("invalid key encoding")
} }
log.Printf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error()) endpointStatus, err := store.Get().GetEndpointStatusByKey(key, paging.NewEndpointStatusParams().WithResults(page, pageSize).WithEvents(1, cfg.Storage.MaximumNumberOfEvents))
return c.Status(500).SendString(err.Error()) if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
}
logr.Errorf("[api.EndpointStatus] Failed to retrieve endpoint status: %s", err.Error())
return c.Status(500).SendString(err.Error())
}
if endpointStatus == nil { // XXX: is this check necessary?
logr.Errorf("[api.EndpointStatus] Endpoint with key=%s not found", key)
return c.Status(404).SendString("not found")
}
output, err := json.Marshal(endpointStatus)
if err != nil {
logr.Errorf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
c.Set("Content-Type", "application/json")
return c.Status(200).Send(output)
} }
if endpointStatus == nil { // XXX: is this check necessary?
log.Printf("[api.EndpointStatus] Endpoint with key=%s not found", c.Params("key"))
return c.Status(404).SendString("not found")
}
output, err := json.Marshal(endpointStatus)
if err != nil {
log.Printf("[api.EndpointStatus] Unable to marshal object to JSON: %s", err.Error())
return c.Status(500).SendString("unable to marshal object to JSON")
}
c.Set("Content-Type", "application/json")
return c.Status(200).Send(output)
} }
+12 -1
View File
@@ -9,6 +9,7 @@ import (
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint" "github.com/TwiN/gatus/v5/config/endpoint"
"github.com/TwiN/gatus/v5/storage"
"github.com/TwiN/gatus/v5/storage/store" "github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
) )
@@ -95,6 +96,10 @@ func TestEndpointStatus(t *testing.T) {
Group: "core", Group: "core",
}, },
}, },
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
} }
watchdog.UpdateEndpointStatuses(cfg.Endpoints[0], &endpoint.Result{Success: true, Duration: time.Millisecond, Timestamp: time.Now()}) 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.UpdateEndpointStatuses(cfg.Endpoints[1], &endpoint.Result{Success: false, Duration: time.Second, Timestamp: time.Now()})
@@ -156,7 +161,13 @@ func TestEndpointStatuses(t *testing.T) {
// Can't be bothered dealing with timezone issues on the worker that runs the automated tests // Can't be bothered dealing with timezone issues on the worker that runs the automated tests
firstResult.Timestamp = time.Time{} firstResult.Timestamp = time.Time{}
secondResult.Timestamp = time.Time{} secondResult.Timestamp = time.Time{}
api := New(&config.Config{Metrics: true}) api := New(&config.Config{
Metrics: true,
Storage: &storage.Config{
MaximumNumberOfResults: storage.DefaultMaximumNumberOfResults,
MaximumNumberOfEvents: storage.DefaultMaximumNumberOfEvents,
},
})
router := api.Router() router := api.Router()
type Scenario struct { type Scenario struct {
Name string Name string
+22 -6
View File
@@ -2,19 +2,21 @@ package api
import ( import (
"errors" "errors"
"log"
"strings" "strings"
"time" "time"
"github.com/TwiN/gatus/v5/config" "github.com/TwiN/gatus/v5/config"
"github.com/TwiN/gatus/v5/config/endpoint" "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"
"github.com/TwiN/gatus/v5/storage/store/common" "github.com/TwiN/gatus/v5/storage/store/common"
"github.com/TwiN/gatus/v5/watchdog" "github.com/TwiN/gatus/v5/watchdog"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler { func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
extraLabels := cfg.GetUniqueExtraMetricLabels()
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
// Check if the success query parameter is present // Check if the success query parameter is present
success, exists := c.Queries()["success"] success, exists := c.Queries()["success"]
@@ -33,11 +35,11 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
key := c.Params("key") key := c.Params("key")
externalEndpoint := cfg.GetExternalEndpointByKey(key) externalEndpoint := cfg.GetExternalEndpointByKey(key)
if externalEndpoint == nil { if externalEndpoint == nil {
log.Printf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key) logr.Errorf("[api.CreateExternalEndpointResult] External endpoint with key=%s not found", key)
return c.Status(404).SendString("not found") return c.Status(404).SendString("not found")
} }
if externalEndpoint.Token != token { if externalEndpoint.Token != token {
log.Printf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key) logr.Errorf("[api.CreateExternalEndpointResult] Invalid token for external endpoint with key=%s", key)
return c.Status(401).SendString("invalid token") return c.Status(401).SendString("invalid token")
} }
// Persist the result in the storage // Persist the result in the storage
@@ -46,21 +48,35 @@ func CreateExternalEndpointResult(cfg *config.Config) fiber.Handler {
Success: c.QueryBool("success"), Success: c.QueryBool("success"),
Errors: []string{}, Errors: []string{},
} }
if len(c.Query("duration")) > 0 {
parsedDuration, err := time.ParseDuration(c.Query("duration"))
if err != nil {
logr.Errorf("[api.CreateExternalEndpointResult] Invalid duration from string=%s with error: %s", c.Query("duration"), err.Error())
return c.Status(400).SendString("invalid duration: " + err.Error())
}
result.Duration = parsedDuration
}
if !result.Success && c.Query("error") != "" {
result.Errors = append(result.Errors, c.Query("error"))
}
convertedEndpoint := externalEndpoint.ToEndpoint() convertedEndpoint := externalEndpoint.ToEndpoint()
if err := store.Get().Insert(convertedEndpoint, result); err != nil { if err := store.Get().Insert(convertedEndpoint, result); err != nil {
if errors.Is(err, common.ErrEndpointNotFound) { if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error()) return c.Status(404).SendString(err.Error())
} }
log.Printf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error()) logr.Errorf("[api.CreateExternalEndpointResult] Failed to insert result in storage: %s", err.Error())
return c.Status(500).SendString(err.Error()) return c.Status(500).SendString(err.Error())
} }
log.Printf("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success) logr.Infof("[api.CreateExternalEndpointResult] Successfully inserted result for external endpoint with key=%s and success=%s", c.Params("key"), success)
// Check if an alert should be triggered or resolved // Check if an alert should be triggered or resolved
if !cfg.Maintenance.IsUnderMaintenance() { if !cfg.Maintenance.IsUnderMaintenance() {
watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting, cfg.Debug) watchdog.HandleAlerting(convertedEndpoint, result, cfg.Alerting)
externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow externalEndpoint.NumberOfSuccessesInARow = convertedEndpoint.NumberOfSuccessesInARow
externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow externalEndpoint.NumberOfFailuresInARow = convertedEndpoint.NumberOfFailuresInARow
} }
if cfg.Metrics {
metrics.PublishMetricsForEndpoint(convertedEndpoint, result, extraLabels)
}
// Return the result // Return the result
return c.Status(200).SendString("") return c.Status(200).SendString("")
} }
+48 -9
View File
@@ -70,12 +70,30 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400, ExpectedCode: 400,
}, },
{
Name: "bad-duration-value",
Path: "/api/v1/endpoints/g_n/external?success=true&duration=invalid",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 400,
},
{ {
Name: "good-token-success-true", Name: "good-token-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true", Path: "/api/v1/endpoints/g_n/external?success=true",
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200, ExpectedCode: 200,
}, },
{
Name: "good-token-success-true-with-ignored-error-because-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{
Name: "good-duration-success-true",
Path: "/api/v1/endpoints/g_n/external?success=true&duration=10s",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
{ {
Name: "good-token-success-false", Name: "good-token-success-false",
Path: "/api/v1/endpoints/g_n/external?success=false", Path: "/api/v1/endpoints/g_n/external?success=false",
@@ -88,6 +106,12 @@ func TestCreateExternalEndpointResult(t *testing.T) {
AuthorizationHeaderBearerToken: "Bearer token", AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200, ExpectedCode: 200,
}, },
{
Name: "good-token-success-false-with-error",
Path: "/api/v1/endpoints/g_n/external?success=false&error=failed",
AuthorizationHeaderBearerToken: "Bearer token",
ExpectedCode: 200,
},
} }
for _, scenario := range scenarios { for _, scenario := range scenarios {
t.Run(scenario.Name, func(t *testing.T) { t.Run(scenario.Name, func(t *testing.T) {
@@ -106,7 +130,7 @@ func TestCreateExternalEndpointResult(t *testing.T) {
}) })
} }
t.Run("verify-end-results", func(t *testing.T) { t.Run("verify-end-results", func(t *testing.T) {
endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 10)) endpointStatus, err := store.Get().GetEndpointStatus("g", "n", paging.NewEndpointStatusParams().WithResults(1, 11))
if err != nil { if err != nil {
t.Errorf("failed to get endpoint status: %s", err.Error()) t.Errorf("failed to get endpoint status: %s", err.Error())
return return
@@ -114,21 +138,36 @@ func TestCreateExternalEndpointResult(t *testing.T) {
if endpointStatus.Key != "g_n" { if endpointStatus.Key != "g_n" {
t.Errorf("expected key to be g_n but got %s", endpointStatus.Key) t.Errorf("expected key to be g_n but got %s", endpointStatus.Key)
} }
if len(endpointStatus.Results) != 3 { if len(endpointStatus.Results) != 6 {
t.Errorf("expected 3 results but got %d", len(endpointStatus.Results)) t.Errorf("expected 6 results but got %d", len(endpointStatus.Results))
} }
if !endpointStatus.Results[0].Success { if !endpointStatus.Results[0].Success {
t.Errorf("expected first result to be successful") t.Errorf("expected first result to be successful")
} }
if endpointStatus.Results[1].Success { if !endpointStatus.Results[1].Success {
t.Errorf("expected second result to be unsuccessful") t.Errorf("expected second result to be successful")
} }
if endpointStatus.Results[2].Success { if len(endpointStatus.Results[1].Errors) > 0 {
t.Errorf("expected third result to be unsuccessful") t.Errorf("expected second result to have no errors")
}
if endpointStatus.Results[2].Duration == 0 || endpointStatus.Results[2].Duration.Seconds() != 10 {
t.Errorf("expected third result to have a duration of 10 seconds")
}
if endpointStatus.Results[3].Success {
t.Errorf("expected fourth result to be unsuccessful")
}
if endpointStatus.Results[4].Success {
t.Errorf("expected fifth result to be unsuccessful")
}
if endpointStatus.Results[5].Success {
t.Errorf("expected sixth result to be unsuccessful")
}
if len(endpointStatus.Results[5].Errors) == 0 || endpointStatus.Results[5].Errors[0] != "failed" {
t.Errorf("expected sixth result to have errors: failed")
} }
externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n") externalEndpointFromConfig := cfg.GetExternalEndpointByKey("g_n")
if externalEndpointFromConfig.NumberOfFailuresInARow != 2 { if externalEndpointFromConfig.NumberOfFailuresInARow != 3 {
t.Errorf("expected 2 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow) t.Errorf("expected 3 failures in a row but got %d", externalEndpointFromConfig.NumberOfFailuresInARow)
} }
if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 { if externalEndpointFromConfig.NumberOfSuccessesInARow != 0 {
t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow) t.Errorf("expected 0 successes in a row but got %d", externalEndpointFromConfig.NumberOfSuccessesInARow)
+75
View File
@@ -0,0 +1,75 @@
package api
import (
"errors"
"fmt"
"time"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/storage/store/common"
"github.com/gofiber/fiber/v2"
)
func UptimeRaw(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
uptime, err := store.Get().GetUptimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "text/plain")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send([]byte(fmt.Sprintf("%f", uptime)))
}
func ResponseTimeRaw(c *fiber.Ctx) error {
duration := c.Params("duration")
var from time.Time
switch duration {
case "30d":
from = time.Now().Add(-30 * 24 * time.Hour)
case "7d":
from = time.Now().Add(-7 * 24 * time.Hour)
case "24h":
from = time.Now().Add(-24 * time.Hour)
case "1h":
from = time.Now().Add(-2 * time.Hour) // Because uptime metrics are stored by hour, we have to cheat a little
default:
return c.Status(400).SendString("Durations supported: 30d, 7d, 24h, 1h")
}
key := c.Params("key")
responseTime, err := store.Get().GetAverageResponseTimeByKey(key, from, time.Now())
if err != nil {
if errors.Is(err, common.ErrEndpointNotFound) {
return c.Status(404).SendString(err.Error())
} else if errors.Is(err, common.ErrInvalidTimeRange) {
return c.Status(400).SendString(err.Error())
}
return c.Status(500).SendString(err.Error())
}
c.Set("Content-Type", "text/plain")
c.Set("Cache-Control", "no-cache, no-store, must-revalidate")
c.Set("Expires", "0")
return c.Status(200).Send([]byte(fmt.Sprintf("%d", responseTime)))
}
+123
View File
@@ -0,0 +1,123 @@
package api
import (
"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/endpoint/ui"
"github.com/TwiN/gatus/v5/storage/store"
"github.com/TwiN/gatus/v5/watchdog"
)
func TestRawDataEndpoint(t *testing.T) {
defer store.Get().Clear()
defer cache.Clear()
cfg := &config.Config{
Metrics: true,
Endpoints: []*endpoint.Endpoint{
{
Name: "frontend",
Group: "core",
},
{
Name: "backend",
Group: "core",
},
},
}
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()})
api := New(cfg)
router := api.Router()
type Scenario struct {
Name string
Path string
ExpectedCode int
Gzip bool
}
scenarios := []Scenario{
{
Name: "raw-uptime-1h",
Path: "/api/v1/endpoints/core_frontend/uptimes/1h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-24h",
Path: "/api/v1/endpoints/core_backend/uptimes/24h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-7d",
Path: "/api/v1/endpoints/core_frontend/uptimes/7d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-30d",
Path: "/api/v1/endpoints/core_frontend/uptimes/30d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-uptime-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/uptimes/3d",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "raw-uptime-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/uptimes/7d",
ExpectedCode: http.StatusNotFound,
},
{
Name: "raw-response-times-1h",
Path: "/api/v1/endpoints/core_frontend/response-times/1h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-24h",
Path: "/api/v1/endpoints/core_backend/response-times/24h",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-7d",
Path: "/api/v1/endpoints/core_frontend/response-times/7d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-30d",
Path: "/api/v1/endpoints/core_frontend/response-times/30d",
ExpectedCode: http.StatusOK,
},
{
Name: "raw-response-times-with-invalid-duration",
Path: "/api/v1/endpoints/core_backend/response-times/3d",
ExpectedCode: http.StatusBadRequest,
},
{
Name: "raw-response-times-for-invalid-key",
Path: "/api/v1/endpoints/invalid_key/response-times/7d",
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)
}
})
}
}
+16 -5
View File
@@ -3,26 +3,37 @@ package api
import ( import (
_ "embed" _ "embed"
"html/template" "html/template"
"log"
"github.com/TwiN/gatus/v5/config/ui" "github.com/TwiN/gatus/v5/config/ui"
static "github.com/TwiN/gatus/v5/web" static "github.com/TwiN/gatus/v5/web"
"github.com/TwiN/logr"
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
) )
func SinglePageApplication(ui *ui.Config) fiber.Handler { func SinglePageApplication(uiConfig *ui.Config) fiber.Handler {
return func(c *fiber.Ctx) error { return func(c *fiber.Ctx) error {
vd := ui.ViewData{UI: uiConfig}
{
themeFromCookie := string(c.Request().Header.Cookie("theme"))
if len(themeFromCookie) > 0 {
if themeFromCookie == "dark" {
vd.Theme = "dark"
}
} else if uiConfig.IsDarkMode() { // Since there's no theme cookie, we'll rely on ui.DarkMode
vd.Theme = "dark"
}
}
t, err := template.ParseFS(static.FileSystem, static.IndexPath) t, err := template.ParseFS(static.FileSystem, static.IndexPath)
if err != nil { if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works. // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error:", err.Error()) logr.Errorf("[api.SinglePageApplication] Failed to parse template. This should never happen, because the template is validated on start. Error: %s", err.Error())
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.") return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
} }
c.Set("Content-Type", "text/html") c.Set("Content-Type", "text/html")
err = t.Execute(c, ui) err = t.Execute(c, vd)
if err != nil { if err != nil {
// This should never happen, because ui.ValidateAndSetDefaults validates that the template works. // This should never happen, because ui.ValidateAndSetDefaults validates that the template works.
log.Println("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error:", err.Error()) logr.Errorf("[api.SinglePageApplication] Failed to execute template. This should never happen, because the template is validated on start. Error: %s", err.Error())
return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.") return c.Status(500).SendString("Failed to parse template. This should never happen, because the template is validated on start.")
} }
return c.SendStatus(200) return c.SendStatus(200)

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